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 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 19:58:44 -05:00
co-authored by Claude Opus 4.8
parent 54a88e1f31
commit 463e80709f
8 changed files with 778 additions and 146 deletions
@@ -24,6 +24,10 @@
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.0.1" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="LibreHardwareMonitor.Windows.WinUI.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LibreHardwareMonitorLib\LibreHardwareMonitorLib.csproj" />
</ItemGroup>
@@ -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()
@@ -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<string, object?> 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<string, object?> 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<string, object?> result = new() { ["result"] = "ok" };
@@ -393,7 +428,7 @@ public sealed class RemoteWebServer : IDisposable
response.Close();
}
private Dictionary<string, object?> GenerateJsonForNode(SensorTreeItemViewModel node, ref int nodeIndex)
internal static Dictionary<string, object?> GenerateJsonForNode(SensorTreeItemViewModel node, ref int nodeIndex)
{
Dictionary<string, object?> jsonNode = new()
{
@@ -432,7 +467,7 @@ public sealed class RemoteWebServer : IDisposable
private async Task SendPrometheusAsync(HttpListenerResponse response, HttpListenerRequest request)
{
Dictionary<string, int> settings = ParsePrometheusSettings(request);
Dictionary<string, int> 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<string, int> ParsePrometheusSettings(HttpListenerRequest request)
internal static Dictionary<string, int> ParsePrometheusSettings(IDictionary<string, string> query)
{
Dictionary<string, string> 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<string, string> query, string key, int fallback, int min, int max)
internal static int ClampQueryValue(IDictionary<string, string> 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<string, int> settings)
internal static string GeneratePrometheusResponse(SensorTreeItemViewModel? root, Dictionary<string, int> 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<string, string> ParseQuery(string? query)
internal static IDictionary<string, string> ParseQuery(string? query)
{
Dictionary<string, string> result = new(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(query))
@@ -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);
}