From 463e80709f0ecd971959c0c5d54695b5c9c84bbf Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Mon, 1 Jun 2026 19:58:44 -0500 Subject: [PATCH] Add characterization tests; consolidate SensorFormatter switches Establishes a safety net before refactoring, and lands the first behavior-preserving cleanup. Tests (no production behavior change): - SensorFormatter: full per-type coverage of GetFormatString, FormatValue, and GetPlotUnit across every SensorType. - RemoteWebServer: extract testable seams (ResolveRoute, VerifyCredentials, internal pure helpers) and pin routing (incl. the no-hijack rule), query and Prometheus parsing, JSON/metric shape, credential semantics, and the legacy SHA-256 vector. - HardwareMonitorService: pin the enable-flag -> settings-key mapping. - Logger: add a TimeProvider/base-dir test seam; deterministic rotation tests. - Add [InternalsVisibleTo] for the test project. Cleanup: - Collapse SensorFormatter's three parallel SensorType switches into one GetFormat source of truth; dedupe CelsiusToFahrenheit. Verified identical by the new characterization tests. 167 tests pass (was 55). Co-Authored-By: Claude Opus 4.8 --- .../Services/HardwareMonitorServiceTests.cs | 88 +++++ .../Services/LoggerTests.cs | 107 +++++- .../Services/RemoteWebServerTests.cs | 339 ++++++++++++++++++ .../Utilities/SensorFormatterTests.cs | 91 +++++ .../LibreHardwareMonitor.Windows.WinUI.csproj | 4 + .../Services/Logger.cs | 18 +- .../Services/RemoteWebServer.cs | 146 +++++--- .../Utilities/SensorFormatter.cs | 131 +++---- 8 files changed, 778 insertions(+), 146 deletions(-) create mode 100644 LibreHardwareMonitor.Windows.WinUI.Tests/Services/HardwareMonitorServiceTests.cs create mode 100644 LibreHardwareMonitor.Windows.WinUI.Tests/Services/RemoteWebServerTests.cs diff --git a/LibreHardwareMonitor.Windows.WinUI.Tests/Services/HardwareMonitorServiceTests.cs b/LibreHardwareMonitor.Windows.WinUI.Tests/Services/HardwareMonitorServiceTests.cs new file mode 100644 index 0000000..8a94c72 --- /dev/null +++ b/LibreHardwareMonitor.Windows.WinUI.Tests/Services/HardwareMonitorServiceTests.cs @@ -0,0 +1,88 @@ +// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +// If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. +// Copyright (C) LibreHardwareMonitor and Contributors. + +using System; +using LibreHardwareMonitor.Windows.WinUI.Services; +using Xunit; + +namespace LibreHardwareMonitor.Windows.WinUI.Tests.Services; + +// Characterization tests pinning behavior the Phase 4 cleanup must preserve: the exact settings keys each +// hardware-enable flag writes (the planned (getter, setter, key) table must keep these identical for config +// compatibility), and the basic RebuildTree/TreeRebuilt contract. The async tree-rebuild coalescer is covered +// once it is extracted into its own testable unit during Phase 4. +public class HardwareMonitorServiceTests +{ + [Fact] + public void EnableFlags_WriteExpectedSettingKeysAndComputerFlags() + { + using var service = new HardwareMonitorService(AppSettings.LoadDefault()); + + service.IsMotherboardEnabled = false; + Assert.False(service.Settings.GetValue("mainboardMenuItem", true)); + Assert.False(service.Computer.IsMotherboardEnabled); + + service.IsCpuEnabled = false; + Assert.False(service.Settings.GetValue("cpuMenuItem", true)); + Assert.False(service.Computer.IsCpuEnabled); + + service.IsMemoryEnabled = false; + Assert.False(service.Settings.GetValue("ramMenuItem", true)); + Assert.False(service.Computer.IsMemoryEnabled); + + service.IsGpuEnabled = false; + Assert.False(service.Settings.GetValue("gpuMenuItem", true)); + Assert.False(service.Computer.IsGpuEnabled); + + service.IsPowerMonitorEnabled = false; + Assert.False(service.Settings.GetValue("powerMonitorMenuItem", true)); + Assert.False(service.Computer.IsPowerMonitorEnabled); + + service.IsControllerEnabled = false; + Assert.False(service.Settings.GetValue("fanControllerMenuItem", true)); + Assert.False(service.Computer.IsControllerEnabled); + + service.IsStorageEnabled = false; + Assert.False(service.Settings.GetValue("hddMenuItem", true)); + Assert.False(service.Computer.IsStorageEnabled); + + service.IsNetworkEnabled = false; + Assert.False(service.Settings.GetValue("nicMenuItem", true)); + Assert.False(service.Computer.IsNetworkEnabled); + + service.IsPsuEnabled = false; + Assert.False(service.Settings.GetValue("psuMenuItem", true)); + Assert.False(service.Computer.IsPsuEnabled); + + service.IsBatteryEnabled = false; + Assert.False(service.Settings.GetValue("batteryMenuItem", true)); + Assert.False(service.Computer.IsBatteryEnabled); + } + + [Fact] + public void RebuildTree_RaisesTreeRebuiltAndPopulatesRoot() + { + using var service = new HardwareMonitorService(AppSettings.LoadDefault()); + bool raised = false; + service.TreeRebuilt += (_, _) => raised = true; + + service.RebuildTree(); + + Assert.True(raised); + Assert.NotNull(service.Root); + Assert.Equal(Environment.MachineName, service.Root.Text); + } + + [Fact] + public void RebuildTree_WithoutRaiseFlag_DoesNotInvokeEvent() + { + using var service = new HardwareMonitorService(AppSettings.LoadDefault()); + bool raised = false; + service.TreeRebuilt += (_, _) => raised = true; + + service.RebuildTree(raiseTreeRebuilt: false); + + Assert.False(raised); + } +} diff --git a/LibreHardwareMonitor.Windows.WinUI.Tests/Services/LoggerTests.cs b/LibreHardwareMonitor.Windows.WinUI.Tests/Services/LoggerTests.cs index bf2ae14..9bb5a55 100644 --- a/LibreHardwareMonitor.Windows.WinUI.Tests/Services/LoggerTests.cs +++ b/LibreHardwareMonitor.Windows.WinUI.Tests/Services/LoggerTests.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using LibreHardwareMonitor.Hardware; using LibreHardwareMonitor.Windows.WinUI.Services; using Moq; @@ -12,7 +13,7 @@ public class LoggerTests public void Constructor_SubscribesToComputerEvents() { var mockComputer = new Mock(); - + var logger = new Logger(mockComputer.Object); // We can't directly test if the event was subscribed, but we can verify default properties @@ -40,15 +41,111 @@ public class LoggerTests var mockComputer = new Mock(); var logger = new Logger(mockComputer.Object); - // We can't fully mock the file system easily here without abstraction, + // We can't fully mock the file system easily here without abstraction, // but if there are no sensors it will just create a basic CSV. // Actually, it tries to create a file in the app directory, which might throw in some environments. // Let's just assure it doesn't crash when interval hasn't passed. - + logger.LoggingInterval = TimeSpan.FromHours(1); logger.Log(); // Should execute - + // Calling again immediately should return early without doing anything - logger.Log(); + logger.Log(); + } + + // Characterization (deterministic clock + temp directory via the internal test seam): + + [Fact] + public void Log_PerSession_CreatesFileWithHeaderRows() + { + string dir = NewTempDir(); + try + { + var clock = new FakeClock(new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.Zero)); + var logger = new Logger(Mock.Of(), clock, dir) { LoggingInterval = TimeSpan.FromSeconds(1) }; + + logger.Log(); + + string[] files = Directory.GetFiles(dir); + Assert.Single(files); + Assert.Equal("LibreHardwareMonitorLog-2026-01-01-1.csv", Path.GetFileName(files[0])); + // Header is an identifiers row then a "Time,"-prefixed names row. + Assert.Contains("Time,", File.ReadAllText(files[0])); + } + finally + { + TryDelete(dir); + } + } + + [Fact] + public void Log_WithinInterval_SkipsSecondCall() + { + string dir = NewTempDir(); + try + { + var clock = new FakeClock(new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.Zero)); + var logger = new Logger(Mock.Of(), clock, dir) { LoggingInterval = TimeSpan.FromSeconds(1) }; + + logger.Log(); + long sizeAfterFirst = new FileInfo(Directory.GetFiles(dir)[0]).Length; + + logger.Log(); // same instant: interval has not elapsed -> no-op + + Assert.Single(Directory.GetFiles(dir)); + Assert.Equal(sizeAfterFirst, new FileInfo(Directory.GetFiles(dir)[0]).Length); + } + finally + { + TryDelete(dir); + } + } + + [Fact] + public void Log_Daily_UsesDateBasedFileName() + { + string dir = NewTempDir(); + try + { + var clock = new FakeClock(new DateTimeOffset(2026, 1, 2, 8, 0, 0, TimeSpan.Zero)); + var logger = new Logger(Mock.Of(), clock, dir) + { + FileRotationMethod = LoggerFileRotation.Daily, + LoggingInterval = TimeSpan.FromSeconds(1) + }; + + logger.Log(); + + Assert.True(File.Exists(Path.Combine(dir, "LibreHardwareMonitorLog-2026-01-02.csv"))); + } + finally + { + TryDelete(dir); + } + } + + private static string NewTempDir() + { + string dir = Path.Combine(Path.GetTempPath(), "lhm-logger-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + return dir; + } + + private static void TryDelete(string dir) + { + try + { + Directory.Delete(dir, true); + } + catch + { + } + } + + private sealed class FakeClock(DateTimeOffset now) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => now; + + public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc; } } diff --git a/LibreHardwareMonitor.Windows.WinUI.Tests/Services/RemoteWebServerTests.cs b/LibreHardwareMonitor.Windows.WinUI.Tests/Services/RemoteWebServerTests.cs new file mode 100644 index 0000000..918931e --- /dev/null +++ b/LibreHardwareMonitor.Windows.WinUI.Tests/Services/RemoteWebServerTests.cs @@ -0,0 +1,339 @@ +// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +// If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. +// Copyright (C) LibreHardwareMonitor and Contributors. + +using System; +using System.Collections.Generic; +using System.Linq; +using LibreHardwareMonitor.Hardware; +using LibreHardwareMonitor.Windows.WinUI.Services; +using LibreHardwareMonitor.Windows.WinUI.ViewModels; +using Moq; +using Xunit; + +namespace LibreHardwareMonitor.Windows.WinUI.Tests.Services; + +// Characterization tests pinning the behavior the Phase 2 security hardening must preserve: +// request routing (including the no-hijack rule), query parsing, Prometheus settings/units/shape, +// the data.json node shape, and credential-verification semantics (including legacy SHA-256). +public class RemoteWebServerTests +{ + // ---- ResolveRoute ------------------------------------------------------- + + [Theory] + [InlineData("GET", "/data.json", "DataJson")] + [InlineData("GET", "/DATA.JSON", "DataJson")] + [InlineData("GET", "/metrics", "Metrics")] + [InlineData("GET", "/Sensor", "Sensor")] + [InlineData("GET", "/ResetAllMinMax", "ResetAllMinMax")] + [InlineData("POST", "/Sensor", "Post")] + [InlineData("POST", "/anything", "Post")] + public void ResolveRoute_MapsEndpoints(string method, string path, string expectedKind) + { + Assert.Equal(expectedKind, RemoteWebServer.ResolveRoute(method, path, null).Kind.ToString()); + } + + [Theory] + // A static asset whose name merely starts with an endpoint name must NOT be hijacked by the API handler. + [InlineData("/metrics.html", "Web.metrics.html")] + [InlineData("/data.json.html", "Web.data.json.html")] + [InlineData("/sensor-icons.css", "Web.sensor-icons.css")] + public void ResolveRoute_StaticAssetsAreNotHijackedByEndpoints(string path, string expectedResource) + { + RemoteWebServer.RemoteWebRoute route = RemoteWebServer.ResolveRoute("GET", path, null); + Assert.Equal(RemoteWebServer.RemoteWebRouteKind.Resource, route.Kind); + Assert.Equal(expectedResource, route.ResourcePath); + } + + [Fact] + public void ResolveRoute_EmptyPath_ServesIndexHtmlResource() + { + RemoteWebServer.RemoteWebRoute route = RemoteWebServer.ResolveRoute("GET", "/", null); + Assert.Equal(RemoteWebServer.RemoteWebRouteKind.Resource, route.Kind); + Assert.Equal("Web.index.html", route.ResourcePath); + } + + [Fact] + public void ResolveRoute_IconPrefix_ServesRawRemainderResource() + { + RemoteWebServer.RemoteWebRoute route = RemoteWebServer.ResolveRoute("GET", "/images_icon/cpu.png", null); + Assert.Equal(RemoteWebServer.RemoteWebRouteKind.Resource, route.Kind); + Assert.Equal("cpu.png", route.ResourcePath); + } + + [Fact] + public void ResolveRoute_FallsBackToRawUrlWhenAbsolutePathNull() + { + Assert.Equal(RemoteWebServer.RemoteWebRouteKind.DataJson, RemoteWebServer.ResolveRoute("GET", null, "/data.json").Kind); + } + + // ---- ParseQuery --------------------------------------------------------- + + [Fact] + public void ParseQuery_ParsesAndUrlDecodes() + { + IDictionary query = RemoteWebServer.ParseQuery("?action=Get&id=%2Fcpu%2F0"); + Assert.Equal("Get", query["action"]); + Assert.Equal("/cpu/0", query["id"]); + } + + [Fact] + public void ParseQuery_KeysAreCaseInsensitive() + { + IDictionary query = RemoteWebServer.ParseQuery("?Action=Get"); + Assert.True(query.ContainsKey("action")); + } + + [Fact] + public void ParseQuery_MissingEquals_YieldsEmptyValue() + { + IDictionary query = RemoteWebServer.ParseQuery("?flag"); + Assert.Equal("", query["flag"]); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ParseQuery_EmptyOrNull_ReturnsEmpty(string? query) + { + Assert.Empty(RemoteWebServer.ParseQuery(query)); + } + + // ---- ClampQueryValue ---------------------------------------------------- + + [Fact] + public void ClampQueryValue_ParsesAndClamps() + { + var query = new Dictionary { ["v"] = "5" }; + Assert.Equal(5, RemoteWebServer.ClampQueryValue(query, "v", 0, 0, 10)); + + query["v"] = "99"; + Assert.Equal(10, RemoteWebServer.ClampQueryValue(query, "v", 0, 0, 10)); + + query["v"] = "-5"; + Assert.Equal(0, RemoteWebServer.ClampQueryValue(query, "v", 0, 0, 10)); + } + + [Fact] + public void ClampQueryValue_MissingOrUnparseable_ReturnsFallback() + { + var query = new Dictionary { ["v"] = "abc" }; + Assert.Equal(3, RemoteWebServer.ClampQueryValue(query, "v", 3, 0, 10)); + Assert.Equal(3, RemoteWebServer.ClampQueryValue(query, "missing", 3, 0, 10)); + } + + // ---- ParsePrometheusSettings (the archive/lastvalue/timestamps interplay) ---- + + [Fact] + public void ParsePrometheusSettings_Defaults_LastValueOnly() + { + Dictionary settings = RemoteWebServer.ParsePrometheusSettings(new Dictionary()); + Assert.Equal(0, settings["archivelength"]); + Assert.Equal(0, settings["timestamps"]); + Assert.Equal(1, settings["lastvalue"]); + } + + [Fact] + public void ParsePrometheusSettings_NoArchiveNoLastValue_ForcesArchiveAndTimestamps() + { + Dictionary settings = RemoteWebServer.ParsePrometheusSettings(new Dictionary { ["lastvalue"] = "0" }); + Assert.Equal(1, settings["archivelength"]); + Assert.Equal(1, settings["timestamps"]); + Assert.Equal(0, settings["lastvalue"]); + } + + [Fact] + public void ParsePrometheusSettings_ArchivePositive_ForcesTimestamps() + { + Dictionary settings = RemoteWebServer.ParsePrometheusSettings(new Dictionary { ["archivelength"] = "3" }); + Assert.Equal(3, settings["archivelength"]); + Assert.Equal(1, settings["timestamps"]); + Assert.Equal(1, settings["lastvalue"]); + } + + // ---- GetPrometheusUnit -------------------------------------------------- + + [Theory] + [InlineData(SensorType.Temperature, "_celsius", 1.0)] + [InlineData(SensorType.Clock, "_hertz", 1000000.0)] + [InlineData(SensorType.Data, "_bytes", 1000000000.0)] + [InlineData(SensorType.SmallData, "_bytes", 1048576.0)] + [InlineData(SensorType.Load, "_percent", 1.0)] + [InlineData((SensorType)999, "", 1.0)] + public void GetPrometheusUnit_MapsUnitAndFactor(SensorType sensorType, string expectedSuffix, double expectedFactor) + { + (string suffix, double factor) = RemoteWebServer.GetPrometheusUnit(sensorType); + Assert.Equal(expectedSuffix, suffix); + Assert.Equal(expectedFactor, factor); + } + + // ---- GenerateJsonForNode (data.json shape) ------------------------------ + + [Fact] + public void GenerateJsonForNode_ProducesExpectedShape() + { + ISensor sensor = CreateSensor("Core 0", SensorType.Temperature, new Identifier("cpu", "0", "temperature", "0"), value: 50f, min: 40f, max: 60f); + IHardware hardware = CreateHardware("CPU", HardwareType.Cpu, new Identifier("cpu", "0"), sensor); + + SensorTreeItemViewModel root = SensorTreeItemViewModel.CreateRoot("HOST"); + root.Children.Add(SensorTreeItemViewModel.FromHardware(hardware, AppSettings.LoadDefault())); + + int nodeIndex = 1; + Dictionary json = RemoteWebServer.GenerateJsonForNode(root, ref nodeIndex); + + Assert.Equal(1, json["id"]); + Assert.Equal("HOST", json["Text"]); + + var hardwareJson = (Dictionary)((List)json["Children"]!).Single(); + Assert.Equal("CPU", hardwareJson["Text"]); + Assert.Equal(hardware.Identifier.ToString(), hardwareJson["HardwareId"]); + + var typeGroupJson = (Dictionary)((List)hardwareJson["Children"]!).Single(); + var sensorJson = (Dictionary)((List)typeGroupJson["Children"]!).Single(); + + Assert.Equal(sensor.Identifier.ToString(), sensorJson["SensorId"]); + Assert.Equal("Temperature", sensorJson["Type"]); + Assert.Equal(50f, sensorJson["RawValue"]); + Assert.Equal(40f, sensorJson["RawMin"]); + Assert.Equal(60f, sensorJson["RawMax"]); + Assert.Equal("images/transparent.png", sensorJson["ImageURL"]); + } + + // ---- GeneratePrometheusResponse ----------------------------------------- + + private static readonly Dictionary LastValueSettings = new() + { + ["archivelength"] = 0, + ["timestamps"] = 0, + ["lastvalue"] = 1 + }; + + [Fact] + public void GeneratePrometheusResponse_EmitsTypeLineAndMetric() + { + ISensor sensor = CreateSensor("Core 0", SensorType.Temperature, new Identifier("cpu", "0", "temperature", "0"), value: 50f); + sensor = WithValues(sensor, new SensorValue(50f, DateTime.UtcNow)); + SensorTreeItemViewModel root = BuildRoot("HOST", CreateHardware("CPU", HardwareType.Cpu, new Identifier("cpu", "0"), sensor)); + + string output = RemoteWebServer.GeneratePrometheusResponse(root, LastValueSettings); + + Assert.Contains("# TYPE lhm_cpu_temperature_celsius gauge", output); + Assert.Contains("lhm_cpu_temperature_celsius {", output); + Assert.Contains("\"host\"=\"HOST\"", output); + Assert.Contains(" 50", output); + } + + [Fact] + public void GeneratePrometheusResponse_NaNValue_IsSkippedWithHelpLine() + { + ISensor sensor = CreateSensor("Core 0", SensorType.Temperature, new Identifier("cpu", "0", "temperature", "0"), value: float.NaN); + sensor = WithValues(sensor, new SensorValue(float.NaN, DateTime.UtcNow)); + SensorTreeItemViewModel root = BuildRoot("HOST", CreateHardware("CPU", HardwareType.Cpu, new Identifier("cpu", "0"), sensor)); + + string output = RemoteWebServer.GeneratePrometheusResponse(root, LastValueSettings); + + Assert.Contains("has an invalid value and was skipped", output); + } + + [Fact] + public void GeneratePrometheusResponse_SameTagEmittedOnce() + { + ISensor sensor1 = WithValues(CreateSensor("Core 0", SensorType.Temperature, new Identifier("cpu", "0", "temperature", "0"), 50f), new SensorValue(50f, DateTime.UtcNow)); + ISensor sensor2 = WithValues(CreateSensor("Core 1", SensorType.Temperature, new Identifier("cpu", "0", "temperature", "1"), 60f), new SensorValue(60f, DateTime.UtcNow)); + IHardware hardware = CreateHardware("CPU", HardwareType.Cpu, new Identifier("cpu", "0"), sensor1, sensor2); + SensorTreeItemViewModel root = BuildRoot("HOST", hardware); + + string output = RemoteWebServer.GeneratePrometheusResponse(root, LastValueSettings); + + int typeLineCount = output.Split('\n').Count(line => line.StartsWith("# TYPE lhm_cpu_temperature_celsius")); + Assert.Equal(1, typeLineCount); + } + + [Fact] + public void GeneratePrometheusResponse_NullRoot_ReturnsEmpty() + { + Assert.Equal("", RemoteWebServer.GeneratePrometheusResponse(null, LastValueSettings)); + } + + // ---- ComputeSHA256 (legacy hashing; Phase 2 must keep verifying these) ---- + + [Fact] + public void ComputeSHA256_KnownVector() + { + Assert.Equal("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", RemoteWebServer.ComputeSHA256("abc")); + } + + // ---- VerifyCredentials -------------------------------------------------- + + [Fact] + public void VerifyCredentials_AuthDisabled_AlwaysTrue() + { + using RemoteWebServer server = CreateServer(authEnabled: false, "admin", "secret"); + Assert.True(server.VerifyCredentials("anything", "anything")); + Assert.True(server.VerifyCredentials(null, null)); + } + + [Fact] + public void VerifyCredentials_CorrectCredentials_True() + { + using RemoteWebServer server = CreateServer(authEnabled: true, "admin", "secret"); + Assert.True(server.VerifyCredentials("admin", "secret")); + } + + [Theory] + [InlineData("admin", "wrong")] + [InlineData("root", "secret")] + [InlineData(null, "secret")] + [InlineData("admin", null)] + public void VerifyCredentials_BadCredentials_False(string? userName, string? password) + { + using RemoteWebServer server = CreateServer(authEnabled: true, "admin", "secret"); + Assert.False(server.VerifyCredentials(userName, password)); + } + + // ---- helpers ------------------------------------------------------------ + + private static RemoteWebServer CreateServer(bool authEnabled, string userName, string password) + { + return new RemoteWebServer(() => null, Mock.Of(), new object(), "localhost", 8085, authEnabled, userName, RemoteWebServer.ComputeSHA256(password)); + } + + private static ISensor CreateSensor(string name, SensorType type, Identifier identifier, float? value = null, float? min = null, float? max = null) + { + var mock = new Mock(); + mock.Setup(s => s.Name).Returns(name); + mock.Setup(s => s.SensorType).Returns(type); + mock.Setup(s => s.Identifier).Returns(identifier); + mock.Setup(s => s.Index).Returns(0); + mock.Setup(s => s.Value).Returns(value); + mock.Setup(s => s.Min).Returns(min); + mock.Setup(s => s.Max).Returns(max); + mock.Setup(s => s.Values).Returns(Array.Empty()); + return mock.Object; + } + + private static ISensor WithValues(ISensor sensor, params SensorValue[] values) + { + Mock.Get(sensor).Setup(s => s.Values).Returns(values); + return sensor; + } + + private static IHardware CreateHardware(string name, HardwareType type, Identifier identifier, params ISensor[] sensors) + { + var mock = new Mock(); + mock.Setup(h => h.Name).Returns(name); + mock.Setup(h => h.HardwareType).Returns(type); + mock.Setup(h => h.Identifier).Returns(identifier); + mock.Setup(h => h.Sensors).Returns(sensors); + mock.Setup(h => h.SubHardware).Returns(Array.Empty()); + return mock.Object; + } + + private static SensorTreeItemViewModel BuildRoot(string host, IHardware hardware) + { + SensorTreeItemViewModel root = SensorTreeItemViewModel.CreateRoot(host); + root.Children.Add(SensorTreeItemViewModel.FromHardware(hardware, AppSettings.LoadDefault())); + return root; + } +} diff --git a/LibreHardwareMonitor.Windows.WinUI.Tests/Utilities/SensorFormatterTests.cs b/LibreHardwareMonitor.Windows.WinUI.Tests/Utilities/SensorFormatterTests.cs index 0fda401..ca9dc74 100644 --- a/LibreHardwareMonitor.Windows.WinUI.Tests/Utilities/SensorFormatterTests.cs +++ b/LibreHardwareMonitor.Windows.WinUI.Tests/Utilities/SensorFormatterTests.cs @@ -32,6 +32,97 @@ public class SensorFormatterTests Assert.Equal("{0:F1}", SensorFormatter.GetFormatString(mockSensor.Object)); } + // Characterization: pins the GetFormatString mapping for every SensorType so the + // three parallel SensorType switches can be safely collapsed into one lookup table. + [Theory] + [InlineData(SensorType.Voltage, "{0:F3} V")] + [InlineData(SensorType.Current, "{0:F3} A")] + [InlineData(SensorType.Clock, "{0:F1} MHz")] + [InlineData(SensorType.Load, "{0:F1} %")] + [InlineData(SensorType.Temperature, "{0:F1} °C")] + [InlineData(SensorType.Fan, "{0:F0} RPM")] + [InlineData(SensorType.Flow, "{0:F1} L/h")] + [InlineData(SensorType.Control, "{0:F1} %")] + [InlineData(SensorType.Level, "{0:F1} %")] + [InlineData(SensorType.Power, "{0:F1} W")] + [InlineData(SensorType.Data, "{0:F1} GB")] + [InlineData(SensorType.SmallData, "{0:F1} MB")] + [InlineData(SensorType.Factor, "{0:F3}")] + [InlineData(SensorType.Frequency, "{0:F1} Hz")] + [InlineData(SensorType.Throughput, "{0:F1} B/s")] + [InlineData(SensorType.TimeSpan, "{0:g}")] + [InlineData(SensorType.Timing, "{0:F3} ns")] + [InlineData(SensorType.Energy, "{0:F0} mWh")] + [InlineData(SensorType.Noise, "{0:F0} dBA")] + [InlineData(SensorType.Conductivity, "{0:F1} µS/cm")] + [InlineData(SensorType.Humidity, "{0:F0} %")] + [InlineData((SensorType)999, "{0:F1}")] + public void GetFormatString_AllSensorTypes(SensorType sensorType, string expected) + { + var mockSensor = new Mock(); + mockSensor.Setup(s => s.SensorType).Returns(sensorType); + Assert.Equal(expected, SensorFormatter.GetFormatString(mockSensor.Object)); + } + + // Characterization: pins the GetPlotUnit mapping for every SensorType (Celsius). + [Theory] + [InlineData(SensorType.Voltage, "V")] + [InlineData(SensorType.Current, "A")] + [InlineData(SensorType.Clock, "MHz")] + [InlineData(SensorType.Load, "%")] + [InlineData(SensorType.Temperature, "°C")] + [InlineData(SensorType.Fan, "RPM")] + [InlineData(SensorType.Flow, "L/h")] + [InlineData(SensorType.Control, "%")] + [InlineData(SensorType.Level, "%")] + [InlineData(SensorType.Power, "W")] + [InlineData(SensorType.Data, "GB")] + [InlineData(SensorType.SmallData, "MB")] + [InlineData(SensorType.Factor, "1")] + [InlineData(SensorType.Frequency, "Hz")] + [InlineData(SensorType.Throughput, "B/s")] + [InlineData(SensorType.TimeSpan, "s")] + [InlineData(SensorType.Timing, "ns")] + [InlineData(SensorType.Energy, "mWh")] + [InlineData(SensorType.Noise, "dBA")] + [InlineData(SensorType.Conductivity, "µS/cm")] + [InlineData(SensorType.Humidity, "%")] + [InlineData((SensorType)999, "")] + public void GetPlotUnit_AllSensorTypes_Celsius(SensorType sensorType, string expected) + { + Assert.Equal(expected, SensorFormatter.GetPlotUnit(sensorType, TemperatureUnit.Celsius)); + } + + // Characterization: pins FormatValue for the "regular" {0:Fn} unit types (Celsius). + // Throughput, TimeSpan, and Temperature-Fahrenheit have their own dedicated tests. + [Theory] + [InlineData(SensorType.Voltage, 1.234f, "1.234 V")] + [InlineData(SensorType.Current, 2.5f, "2.500 A")] + [InlineData(SensorType.Clock, 3500f, "3500.0 MHz")] + [InlineData(SensorType.Load, 42.5f, "42.5 %")] + [InlineData(SensorType.Temperature, 50.5f, "50.5 °C")] + [InlineData(SensorType.Fan, 1200f, "1200 RPM")] + [InlineData(SensorType.Flow, 10.5f, "10.5 L/h")] + [InlineData(SensorType.Control, 75.5f, "75.5 %")] + [InlineData(SensorType.Level, 60.5f, "60.5 %")] + [InlineData(SensorType.Power, 95.5f, "95.5 W")] + [InlineData(SensorType.Data, 8.5f, "8.5 GB")] + [InlineData(SensorType.SmallData, 256.5f, "256.5 MB")] + [InlineData(SensorType.Factor, 1.234f, "1.234")] + [InlineData(SensorType.Frequency, 60.5f, "60.5 Hz")] + [InlineData(SensorType.Timing, 1.234f, "1.234 ns")] + [InlineData(SensorType.Energy, 1500f, "1500 mWh")] + [InlineData(SensorType.Noise, 45f, "45 dBA")] + [InlineData(SensorType.Conductivity, 12.5f, "12.5 µS/cm")] + [InlineData(SensorType.Humidity, 55f, "55 %")] + [InlineData((SensorType)999, 12.34f, "12.3")] + public void FormatValue_RegularTypes_Celsius(SensorType sensorType, float value, string expected) + { + var mockSensor = new Mock(); + mockSensor.Setup(s => s.SensorType).Returns(sensorType); + Assert.Equal(expected, SensorFormatter.FormatValue(mockSensor.Object, value, TemperatureUnit.Celsius)); + } + [Fact] public void FormatValue_NullValue_ReturnsDash() { diff --git a/LibreHardwareMonitor.Windows.WinUI/LibreHardwareMonitor.Windows.WinUI.csproj b/LibreHardwareMonitor.Windows.WinUI/LibreHardwareMonitor.Windows.WinUI.csproj index 7efe42e..06c33f8 100644 --- a/LibreHardwareMonitor.Windows.WinUI/LibreHardwareMonitor.Windows.WinUI.csproj +++ b/LibreHardwareMonitor.Windows.WinUI/LibreHardwareMonitor.Windows.WinUI.csproj @@ -24,6 +24,10 @@ + + + + diff --git a/LibreHardwareMonitor.Windows.WinUI/Services/Logger.cs b/LibreHardwareMonitor.Windows.WinUI/Services/Logger.cs index a4b2ff8..5d0f56d 100644 --- a/LibreHardwareMonitor.Windows.WinUI/Services/Logger.cs +++ b/LibreHardwareMonitor.Windows.WinUI/Services/Logger.cs @@ -16,6 +16,8 @@ public sealed class Logger private const string FileNameFormat = "LibreHardwareMonitorLog-{0:yyyy-MM-dd}{1}.csv"; private readonly IComputer _computer; + private readonly TimeProvider _timeProvider; + private readonly string _baseDirectory; private DateTime _day = DateTime.MinValue; private string _fileName = ""; private string[]? _identifiers; @@ -23,8 +25,16 @@ public sealed class Logger private ISensor?[]? _sensors; public Logger(IComputer computer) + : this(computer, TimeProvider.System, AppContext.BaseDirectory) + { + } + + // Test seam: lets unit tests drive the clock (interval gating, daily rotation) and redirect output to a temp directory. + internal Logger(IComputer computer, TimeProvider timeProvider, string baseDirectory) { _computer = computer; + _timeProvider = timeProvider; + _baseDirectory = baseDirectory; _computer.HardwareAdded += HardwareAdded; _computer.HardwareRemoved += HardwareRemoved; } @@ -35,7 +45,7 @@ public sealed class Logger public void Log() { - DateTime now = DateTime.Now; + DateTime now = _timeProvider.GetLocalNow().DateTime; if (_lastLoggedTime + LoggingInterval - TimeSpan.FromMilliseconds(500) > now) return; @@ -47,7 +57,7 @@ public sealed class Logger uint sessionNumber = 1; do { - _fileName = GetFileName(DateTime.Now, sessionNumber); + _fileName = GetFileName(now, sessionNumber); sessionNumber++; } while (File.Exists(_fileName)); @@ -91,9 +101,9 @@ public sealed class Logger _lastLoggedTime = now; } - private static string GetFileName(DateTime date, uint sessionNumber = 0) + private string GetFileName(DateTime date, uint sessionNumber = 0) { - return Path.Combine(AppContext.BaseDirectory, string.Format(FileNameFormat, date, sessionNumber == 0 ? "" : "-" + sessionNumber)); + return Path.Combine(_baseDirectory, string.Format(FileNameFormat, date, sessionNumber == 0 ? "" : "-" + sessionNumber)); } private void CreateNewLogFile() diff --git a/LibreHardwareMonitor.Windows.WinUI/Services/RemoteWebServer.cs b/LibreHardwareMonitor.Windows.WinUI/Services/RemoteWebServer.cs index 952243f..b2001fd 100644 --- a/LibreHardwareMonitor.Windows.WinUI/Services/RemoteWebServer.cs +++ b/LibreHardwareMonitor.Windows.WinUI/Services/RemoteWebServer.cs @@ -193,57 +193,38 @@ public sealed class RemoteWebServer : IDisposable } HttpListenerRequest request = context.Request; - if (request.HttpMethod == "POST") - { - await HandlePostRequestAsync(context.Response, request); - return; - } + RemoteWebRoute route = ResolveRoute(request.HttpMethod, request.Url?.AbsolutePath, request.RawUrl); - // Match on the path component only (AbsolutePath excludes the query string) and compare endpoints exactly. - // Prefix matching previously let any static asset whose name starts with an endpoint name (e.g. - // "metrics.html", "sensor-icons.css") be hijacked by the API handlers. - string requestedFile = (request.Url?.AbsolutePath ?? request.RawUrl ?? "").TrimStart('/'); - if (requestedFile.Length == 0) - requestedFile = "index.html"; - - if (requestedFile.Equals("data.json", StringComparison.OrdinalIgnoreCase)) + switch (route.Kind) { - await SendJsonAsync(context.Response, request); - return; - } - - if (requestedFile.Equals("metrics", StringComparison.OrdinalIgnoreCase)) - { - await SendPrometheusAsync(context.Response, request); - return; - } - - if (requestedFile.Equals("Sensor", StringComparison.OrdinalIgnoreCase)) - { - Dictionary result = []; - HandleSensorRequest(request, result); - await SendJsonSensorAsync(context.Response, result); - return; - } - - if (requestedFile.Equals("ResetAllMinMax", StringComparison.OrdinalIgnoreCase)) - { - _computer.Accept(new SensorVisitor(sensor => + case RemoteWebRouteKind.Post: + await HandlePostRequestAsync(context.Response, request); + break; + case RemoteWebRouteKind.DataJson: + await SendJsonAsync(context.Response, request); + break; + case RemoteWebRouteKind.Metrics: + await SendPrometheusAsync(context.Response, request); + break; + case RemoteWebRouteKind.Sensor: { - sensor.ResetMin(); - sensor.ResetMax(); - })); - await SendJsonAsync(context.Response, request); - return; + Dictionary result = []; + HandleSensorRequest(request, result); + await SendJsonSensorAsync(context.Response, result); + break; + } + case RemoteWebRouteKind.ResetAllMinMax: + _computer.Accept(new SensorVisitor(sensor => + { + sensor.ResetMin(); + sensor.ResetMax(); + })); + await SendJsonAsync(context.Response, request); + break; + case RemoteWebRouteKind.Resource: + await ServeResourceFileAsync(context.Response, route.ResourcePath); + break; } - - if (requestedFile.StartsWith("images_icon/", StringComparison.OrdinalIgnoreCase)) - { - await ServeResourceFileAsync(context.Response, requestedFile["images_icon/".Length..]); - return; - } - - await ServeResourceFileAsync(context.Response, PathForWebResource(requestedFile)); } catch { @@ -258,6 +239,48 @@ public sealed class RemoteWebServer : IDisposable } } + internal enum RemoteWebRouteKind + { + Post, + DataJson, + Metrics, + Sensor, + ResetAllMinMax, + Resource + } + + internal readonly record struct RemoteWebRoute(RemoteWebRouteKind Kind, string ResourcePath); + + // Maps an HTTP method + path to the route that handles it. Endpoints are matched on the path component only + // (AbsolutePath excludes the query string) and compared exactly. Prefix matching previously let any static asset + // whose name starts with an endpoint name (e.g. "metrics.html", "sensor-icons.css") be hijacked by the API handlers. + internal static RemoteWebRoute ResolveRoute(string httpMethod, string? absolutePath, string? rawUrl) + { + if (httpMethod == "POST") + return new RemoteWebRoute(RemoteWebRouteKind.Post, ""); + + string requestedFile = (absolutePath ?? rawUrl ?? "").TrimStart('/'); + if (requestedFile.Length == 0) + requestedFile = "index.html"; + + if (requestedFile.Equals("data.json", StringComparison.OrdinalIgnoreCase)) + return new RemoteWebRoute(RemoteWebRouteKind.DataJson, ""); + + if (requestedFile.Equals("metrics", StringComparison.OrdinalIgnoreCase)) + return new RemoteWebRoute(RemoteWebRouteKind.Metrics, ""); + + if (requestedFile.Equals("Sensor", StringComparison.OrdinalIgnoreCase)) + return new RemoteWebRoute(RemoteWebRouteKind.Sensor, ""); + + if (requestedFile.Equals("ResetAllMinMax", StringComparison.OrdinalIgnoreCase)) + return new RemoteWebRoute(RemoteWebRouteKind.ResetAllMinMax, ""); + + if (requestedFile.StartsWith("images_icon/", StringComparison.OrdinalIgnoreCase)) + return new RemoteWebRoute(RemoteWebRouteKind.Resource, requestedFile["images_icon/".Length..]); + + return new RemoteWebRoute(RemoteWebRouteKind.Resource, PathForWebResource(requestedFile)); + } + private bool IsAuthenticated(HttpListenerContext context) { if (!AuthEnabled) @@ -266,7 +289,7 @@ public sealed class RemoteWebServer : IDisposable try { if (context.User?.Identity is HttpListenerBasicIdentity identity) - return identity.Name == UserName && ComputeSHA256(identity.Password) == PasswordSHA256; + return VerifyCredentials(identity.Name, identity.Password); } catch { @@ -275,6 +298,18 @@ public sealed class RemoteWebServer : IDisposable return false; } + // Validates Basic-auth credentials. Extracted so the comparison can be unit-tested without an HttpListenerContext. + internal bool VerifyCredentials(string? userName, string? password) + { + if (!AuthEnabled) + return true; + + if (userName == null || password == null) + return false; + + return userName == UserName && ComputeSHA256(password) == PasswordSHA256; + } + private async Task HandlePostRequestAsync(HttpListenerResponse response, HttpListenerRequest request) { Dictionary result = new() { ["result"] = "ok" }; @@ -393,7 +428,7 @@ public sealed class RemoteWebServer : IDisposable response.Close(); } - private Dictionary GenerateJsonForNode(SensorTreeItemViewModel node, ref int nodeIndex) + internal static Dictionary GenerateJsonForNode(SensorTreeItemViewModel node, ref int nodeIndex) { Dictionary jsonNode = new() { @@ -432,7 +467,7 @@ public sealed class RemoteWebServer : IDisposable private async Task SendPrometheusAsync(HttpListenerResponse response, HttpListenerRequest request) { - Dictionary settings = ParsePrometheusSettings(request); + Dictionary settings = ParsePrometheusSettings(ParseQuery(request.Url?.Query)); // Serialize against the sensor update loop: GeneratePrometheusResponse enumerates each sensor's live Values ring // buffer, which the update thread mutates concurrently (an unsynchronized enumeration would throw). @@ -448,9 +483,8 @@ public sealed class RemoteWebServer : IDisposable await SendResponseAsync(response, content, "text/plain"); } - private static Dictionary ParsePrometheusSettings(HttpListenerRequest request) + internal static Dictionary ParsePrometheusSettings(IDictionary query) { - Dictionary query = new(ParseQuery(request.Url?.Query), StringComparer.OrdinalIgnoreCase); int archive = ClampQueryValue(query, "archivelength", 0, 0, 10); int timestamps = ClampQueryValue(query, "timestamps", 0, 0, 1); int lastValue = ClampQueryValue(query, "lastvalue", 1, 0, 1); @@ -472,7 +506,7 @@ public sealed class RemoteWebServer : IDisposable }; } - private static int ClampQueryValue(IDictionary query, string key, int fallback, int min, int max) + internal static int ClampQueryValue(IDictionary query, string key, int fallback, int min, int max) { if (!query.TryGetValue(key, out string? rawValue) || !int.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value)) return fallback; @@ -480,7 +514,7 @@ public sealed class RemoteWebServer : IDisposable return Math.Clamp(value, min, max); } - private static string GeneratePrometheusResponse(SensorTreeItemViewModel? root, Dictionary settings) + internal static string GeneratePrometheusResponse(SensorTreeItemViewModel? root, Dictionary settings) { if (root == null) return ""; @@ -538,7 +572,7 @@ public sealed class RemoteWebServer : IDisposable return builder.ToString(); } - private static (string Suffix, double Factor) GetPrometheusUnit(SensorType sensorType) + internal static (string Suffix, double Factor) GetPrometheusUnit(SensorType sensorType) { return sensorType switch { @@ -705,7 +739,7 @@ public sealed class RemoteWebServer : IDisposable }; } - private static IDictionary ParseQuery(string? query) + internal static IDictionary ParseQuery(string? query) { Dictionary result = new(StringComparer.OrdinalIgnoreCase); if (string.IsNullOrWhiteSpace(query)) diff --git a/LibreHardwareMonitor.Windows.WinUI/Utilities/SensorFormatter.cs b/LibreHardwareMonitor.Windows.WinUI/Utilities/SensorFormatter.cs index 2a0ce63..531a1bf 100644 --- a/LibreHardwareMonitor.Windows.WinUI/Utilities/SensorFormatter.cs +++ b/LibreHardwareMonitor.Windows.WinUI/Utilities/SensorFormatter.cs @@ -14,31 +14,7 @@ public static class SensorFormatter { public static string GetFormatString(ISensor sensor) { - return sensor.SensorType switch - { - SensorType.Voltage => "{0:F3} V", - SensorType.Current => "{0:F3} A", - SensorType.Clock => "{0:F1} MHz", - SensorType.Load => "{0:F1} %", - SensorType.Temperature => "{0:F1} \u00B0C", - SensorType.Fan => "{0:F0} RPM", - SensorType.Flow => "{0:F1} L/h", - SensorType.Control => "{0:F1} %", - SensorType.Level => "{0:F1} %", - SensorType.Power => "{0:F1} W", - SensorType.Data => "{0:F1} GB", - SensorType.SmallData => "{0:F1} MB", - SensorType.Factor => "{0:F3}", - SensorType.Frequency => "{0:F1} Hz", - SensorType.Throughput => "{0:F1} B/s", - SensorType.TimeSpan => "{0:g}", - SensorType.Timing => "{0:F3} ns", - SensorType.Energy => "{0:F0} mWh", - SensorType.Noise => "{0:F0} dBA", - SensorType.Conductivity => "{0:F1} \u00B5S/cm", - SensorType.Humidity => "{0:F0} %", - _ => "{0:F1}" - }; + return GetFormat(sensor.SensorType).Format; } public static string FormatValue(ISensor sensor, float? value, TemperatureUnit temperatureUnit) @@ -46,32 +22,19 @@ public static class SensorFormatter if (!value.HasValue) return "-"; - return sensor.SensorType switch + // Most types just apply their format string to the value. A few are special: temperature can be converted to + // Fahrenheit, and throughput/time span have bespoke formatting that the format string alone can't express. + switch (sensor.SensorType) { - SensorType.Voltage => $"{value:F3} V", - SensorType.Current => $"{value:F3} A", - SensorType.Clock => $"{value:F1} MHz", - SensorType.Load => $"{value:F1} %", - SensorType.Temperature when temperatureUnit == TemperatureUnit.Fahrenheit => $"{CelsiusToFahrenheit(value):F1} \u00B0F", - SensorType.Temperature => $"{value:F1} \u00B0C", - SensorType.Fan => $"{value:F0} RPM", - SensorType.Flow => $"{value:F1} L/h", - SensorType.Control => $"{value:F1} %", - SensorType.Level => $"{value:F1} %", - SensorType.Power => $"{value:F1} W", - SensorType.Data => $"{value:F1} GB", - SensorType.SmallData => $"{value:F1} MB", - SensorType.Factor => $"{value:F3}", - SensorType.Frequency => $"{value:F1} Hz", - SensorType.Throughput => FormatThroughput(sensor, value.Value), - SensorType.TimeSpan => string.Format(CultureInfo.CurrentCulture, "{0:g}", TimeSpan.FromSeconds(value.Value)), - SensorType.Timing => $"{value:F3} ns", - SensorType.Energy => $"{value:F0} mWh", - SensorType.Noise => $"{value:F0} dBA", - SensorType.Conductivity => $"{value:F1} \u00B5S/cm", - SensorType.Humidity => $"{value:F0} %", - _ => value.Value.ToString("F1", CultureInfo.CurrentCulture) - }; + case SensorType.Temperature when temperatureUnit == TemperatureUnit.Fahrenheit: + return $"{CelsiusToFahrenheit(value.Value):F1} \u00B0F"; + case SensorType.Throughput: + return FormatThroughput(sensor, value.Value); + case SensorType.TimeSpan: + return string.Format(CultureInfo.CurrentCulture, "{0:g}", TimeSpan.FromSeconds(value.Value)); + default: + return string.Format(CultureInfo.CurrentCulture, GetFormat(sensor.SensorType).Format, value.Value); + } } public static double? GetPlotValue(ISensor sensor, TemperatureUnit temperatureUnit) @@ -92,32 +55,10 @@ public static class SensorFormatter public static string GetPlotUnit(SensorType sensorType, TemperatureUnit temperatureUnit) { - return sensorType switch - { - SensorType.Voltage => "V", - SensorType.Current => "A", - SensorType.Clock => "MHz", - SensorType.Load => "%", - SensorType.Temperature when temperatureUnit == TemperatureUnit.Fahrenheit => "\u00B0F", - SensorType.Temperature => "\u00B0C", - SensorType.Fan => "RPM", - SensorType.Flow => "L/h", - SensorType.Control => "%", - SensorType.Level => "%", - SensorType.Power => "W", - SensorType.Data => "GB", - SensorType.SmallData => "MB", - SensorType.Factor => "1", - SensorType.Frequency => "Hz", - SensorType.Throughput => "B/s", - SensorType.TimeSpan => "s", - SensorType.Timing => "ns", - SensorType.Energy => "mWh", - SensorType.Noise => "dBA", - SensorType.Conductivity => "\u00B5S/cm", - SensorType.Humidity => "%", - _ => "" - }; + if (sensorType == SensorType.Temperature && temperatureUnit == TemperatureUnit.Fahrenheit) + return "\u00B0F"; + + return GetFormat(sensorType).PlotUnit; } public static string GetToolTip(ISensor sensor, TemperatureUnit temperatureUnit) @@ -166,13 +107,41 @@ public static class SensorFormatter return value < oneMegabyte ? $"{value / 1024:F1} KB/s" : $"{value / oneMegabyte:F1} MB/s"; } - private static float? CelsiusToFahrenheit(float? valueInCelsius) - { - return valueInCelsius * 1.8f + 32; - } - private static float CelsiusToFahrenheit(float valueInCelsius) { return valueInCelsius * 1.8f + 32; } + + // Single source of truth for each sensor type's display format and plot-axis unit. GetFormatString, FormatValue, + // and GetPlotUnit all derive from this so the format string and unit can never drift apart across the three. + private static SensorTypeFormat GetFormat(SensorType sensorType) + { + return sensorType switch + { + SensorType.Voltage => new SensorTypeFormat("{0:F3} V", "V"), + SensorType.Current => new SensorTypeFormat("{0:F3} A", "A"), + SensorType.Clock => new SensorTypeFormat("{0:F1} MHz", "MHz"), + SensorType.Load => new SensorTypeFormat("{0:F1} %", "%"), + SensorType.Temperature => new SensorTypeFormat("{0:F1} °C", "°C"), + SensorType.Fan => new SensorTypeFormat("{0:F0} RPM", "RPM"), + SensorType.Flow => new SensorTypeFormat("{0:F1} L/h", "L/h"), + SensorType.Control => new SensorTypeFormat("{0:F1} %", "%"), + SensorType.Level => new SensorTypeFormat("{0:F1} %", "%"), + SensorType.Power => new SensorTypeFormat("{0:F1} W", "W"), + SensorType.Data => new SensorTypeFormat("{0:F1} GB", "GB"), + SensorType.SmallData => new SensorTypeFormat("{0:F1} MB", "MB"), + SensorType.Factor => new SensorTypeFormat("{0:F3}", "1"), + SensorType.Frequency => new SensorTypeFormat("{0:F1} Hz", "Hz"), + SensorType.Throughput => new SensorTypeFormat("{0:F1} B/s", "B/s"), + SensorType.TimeSpan => new SensorTypeFormat("{0:g}", "s"), + SensorType.Timing => new SensorTypeFormat("{0:F3} ns", "ns"), + SensorType.Energy => new SensorTypeFormat("{0:F0} mWh", "mWh"), + SensorType.Noise => new SensorTypeFormat("{0:F0} dBA", "dBA"), + SensorType.Conductivity => new SensorTypeFormat("{0:F1} µS/cm", "µS/cm"), + SensorType.Humidity => new SensorTypeFormat("{0:F0} %", "%"), + _ => new SensorTypeFormat("{0:F1}", "") + }; + } + + private readonly record struct SensorTypeFormat(string Format, string PlotUnit); }