From 9ffa34d3e7040f2d7d8191100a47ebaba6711745 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 17 Jun 2026 19:45:07 -0400 Subject: [PATCH] =?UTF-8?q?feat(kpi):=20K3=20=E2=80=94=20KpiHistory=20proj?= =?UTF-8?q?ect=20+=20options/validator=20+=20AddKpiHistory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ZB.MOM.WW.ScadaBridge.slnx | 2 + .../KpiHistoryOptions.cs | 38 +++++ .../KpiHistoryOptionsValidator.cs | 58 +++++++ .../ServiceCollectionExtensions.cs | 44 +++++ .../ZB.MOM.WW.ScadaBridge.KpiHistory.csproj | 21 +++ .../KpiHistoryOptionsValidatorTests.cs | 152 ++++++++++++++++++ ...MOM.WW.ScadaBridge.KpiHistory.Tests.csproj | 26 +++ 7 files changed, 341 insertions(+) create mode 100644 src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryOptions.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryOptionsValidator.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.KpiHistory/ServiceCollectionExtensions.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.KpiHistory/ZB.MOM.WW.ScadaBridge.KpiHistory.csproj create mode 100644 tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryOptionsValidatorTests.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests.csproj diff --git a/ZB.MOM.WW.ScadaBridge.slnx b/ZB.MOM.WW.ScadaBridge.slnx index c62d9096..f6ae2c0b 100644 --- a/ZB.MOM.WW.ScadaBridge.slnx +++ b/ZB.MOM.WW.ScadaBridge.slnx @@ -24,6 +24,7 @@ + @@ -53,5 +54,6 @@ + diff --git a/src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryOptions.cs b/src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryOptions.cs new file mode 100644 index 00000000..4f78b476 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryOptions.cs @@ -0,0 +1,38 @@ +namespace ZB.MOM.WW.ScadaBridge.KpiHistory; + +/// +/// Configuration for the KPI History (#26, M6) component. Bound from the +/// ScadaBridge:KpiHistory section of appsettings.json. Defaults +/// reflect the design: a 60-second sampling cadence for the point-in-time +/// Notification Outbox / Site Call Audit KPI snapshots, a 90-day central +/// retention window with a daily purge sweep, and a 200-point default ceiling +/// on the series returned to the Central UI trend charts. +/// +public sealed class KpiHistoryOptions +{ + /// + /// How often the recorder captures a KPI sample row (default 60 s). Must be + /// strictly positive. + /// + public TimeSpan SampleInterval { get; set; } = TimeSpan.FromSeconds(60); + + /// + /// Central retention window in days for recorded KPI samples (default 90, + /// range [1, 3650]). Rows older than this are dropped by the purge + /// sweep. + /// + public int RetentionDays { get; set; } = 90; + + /// + /// How often the purge sweep drops expired sample rows (default 1 day). Must + /// be strictly positive. + /// + public TimeSpan PurgeInterval { get; set; } = TimeSpan.FromDays(1); + + /// + /// Default ceiling on the number of points returned in a single trend series + /// query when the caller does not specify one (default 200, range + /// [2, 5000]). At least two points are required to draw a line. + /// + public int DefaultMaxSeriesPoints { get; set; } = 200; +} diff --git a/src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryOptionsValidator.cs b/src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryOptionsValidator.cs new file mode 100644 index 00000000..f8e48214 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryOptionsValidator.cs @@ -0,0 +1,58 @@ +using ZB.MOM.WW.Configuration; + +namespace ZB.MOM.WW.ScadaBridge.KpiHistory; + +/// +/// Validates on startup. A non-positive +/// or +/// would stall the recorder / +/// purge loops, so both are required to be strictly positive. +/// is bounded to [1, 3650] +/// to keep the purge window sane (too short drops trends still being viewed; +/// too long defeats the retention purpose). +/// is bounded to [2, 5000] — at least two points are needed to draw a +/// line, and an unbounded ceiling would let a single trend query stream an +/// arbitrarily large series to the Central UI. +/// +public sealed class KpiHistoryOptionsValidator : OptionsValidatorBase +{ + /// Inclusive lower bound for . + public const int MinRetentionDays = 1; + + /// Inclusive upper bound for . + public const int MaxRetentionDays = 3650; + + /// Inclusive lower bound for . + public const int MinDefaultMaxSeriesPoints = 2; + + /// Inclusive upper bound for . + public const int MaxDefaultMaxSeriesPoints = 5000; + + /// + protected override void Validate(ValidationBuilder builder, KpiHistoryOptions options) + { + builder.RequireThat(options.SampleInterval > TimeSpan.Zero, + $"ScadaBridge:KpiHistory:{nameof(KpiHistoryOptions.SampleInterval)} ({options.SampleInterval}) " + + "must be > 0; it is the recorder's sampling cadence."); + + // Valid when RetentionDays is within [Min, Max] inclusive. The De Morgan'd + // guard !(below Min OR above Max) is equivalent to (>= Min AND <= Max). + builder.RequireThat( + !(options.RetentionDays < MinRetentionDays || options.RetentionDays > MaxRetentionDays), + $"ScadaBridge:KpiHistory:{nameof(KpiHistoryOptions.RetentionDays)} ({options.RetentionDays}) " + + $"must be in [{MinRetentionDays}, {MaxRetentionDays}] days."); + + builder.RequireThat(options.PurgeInterval > TimeSpan.Zero, + $"ScadaBridge:KpiHistory:{nameof(KpiHistoryOptions.PurgeInterval)} ({options.PurgeInterval}) " + + "must be > 0; it is the purge-sweep cadence."); + + // Valid when DefaultMaxSeriesPoints is within [Min, Max] inclusive. The + // De Morgan'd guard !(below Min OR above Max) is equivalent to + // (>= Min AND <= Max). + builder.RequireThat( + !(options.DefaultMaxSeriesPoints < MinDefaultMaxSeriesPoints + || options.DefaultMaxSeriesPoints > MaxDefaultMaxSeriesPoints), + $"ScadaBridge:KpiHistory:{nameof(KpiHistoryOptions.DefaultMaxSeriesPoints)} ({options.DefaultMaxSeriesPoints}) " + + $"must be in [{MinDefaultMaxSeriesPoints}, {MaxDefaultMaxSeriesPoints}]."); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.KpiHistory/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.KpiHistory/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..f2a51952 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.KpiHistory/ServiceCollectionExtensions.cs @@ -0,0 +1,44 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using ZB.MOM.WW.Configuration; + +namespace ZB.MOM.WW.ScadaBridge.KpiHistory; + +/// +/// Composition root for the KPI History (#26, M6) component. +/// +/// +/// +/// K3 scaffolds the component: it binds and validates +/// . The recorder actor itself is created via +/// Props in the Host on the active central node (K4/K5), not registered +/// here — mirroring the Notification Outbox (#21) singleton wiring. +/// +/// +public static class ServiceCollectionExtensions +{ + /// Configuration section bound to . + public const string OptionsSection = "ScadaBridge:KpiHistory"; + + /// + /// Registers the KPI History (#26) component services: the validated + /// binding. Idempotent re-registration of the + /// validator is handled by the shared AddValidatedOptions helper + /// (TryAddEnumerable); the options binding itself is call-once per + /// . + /// + /// The service collection to register into. + /// Application configuration used to bind . + /// The same for chaining. + public static IServiceCollection AddKpiHistory(this IServiceCollection services, IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + // Binds the "ScadaBridge:KpiHistory" section, registers the validator, + // and enables ValidateOnStart in one call — same shape as AddAuditLog. + services.AddValidatedOptions(configuration, OptionsSection); + + return services; + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.KpiHistory/ZB.MOM.WW.ScadaBridge.KpiHistory.csproj b/src/ZB.MOM.WW.ScadaBridge.KpiHistory/ZB.MOM.WW.ScadaBridge.KpiHistory.csproj new file mode 100644 index 00000000..77d993f3 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.KpiHistory/ZB.MOM.WW.ScadaBridge.KpiHistory.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + + + diff --git a/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryOptionsValidatorTests.cs new file mode 100644 index 00000000..e3969029 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryOptionsValidatorTests.cs @@ -0,0 +1,152 @@ +using ZB.MOM.WW.ScadaBridge.KpiHistory; + +namespace ZB.MOM.WW.ScadaBridge.KpiHistory.Tests; + +/// +/// K3 unit tests for : the default +/// options must validate, and each rule (SampleInterval / RetentionDays / +/// PurgeInterval / DefaultMaxSeriesPoints) must reject its out-of-range values. +/// +public class KpiHistoryOptionsValidatorTests +{ + private static KpiHistoryOptionsValidator NewValidator() => new(); + + [Fact] + public void Validate_Defaults_Pass() + { + var result = NewValidator().Validate(null, new KpiHistoryOptions()); + Assert.True(result.Succeeded); + } + + // --------------------------------------------------------------------- + // SampleInterval > TimeSpan.Zero + // --------------------------------------------------------------------- + + [Fact] + public void Validate_SampleInterval_Zero_Fails() + { + var opts = new KpiHistoryOptions { SampleInterval = TimeSpan.Zero }; + var result = NewValidator().Validate(null, opts); + + Assert.False(result.Succeeded); + Assert.Contains( + result.Failures!, + f => f.Contains(nameof(KpiHistoryOptions.SampleInterval), StringComparison.Ordinal)); + } + + [Fact] + public void Validate_SampleInterval_Negative_Fails() + { + var opts = new KpiHistoryOptions { SampleInterval = TimeSpan.FromSeconds(-1) }; + var result = NewValidator().Validate(null, opts); + + Assert.False(result.Succeeded); + Assert.Contains( + result.Failures!, + f => f.Contains(nameof(KpiHistoryOptions.SampleInterval), StringComparison.Ordinal)); + } + + // --------------------------------------------------------------------- + // RetentionDays in [1, 3650] + // --------------------------------------------------------------------- + + [Theory] + [InlineData(1)] // min + [InlineData(90)] // default + [InlineData(3650)] // max + public void Validate_RetentionDays_InRange_Passes(int value) + { + var opts = new KpiHistoryOptions { RetentionDays = value }; + Assert.True(NewValidator().Validate(null, opts).Succeeded); + } + + [Theory] + [InlineData(0)] + [InlineData(3651)] + [InlineData(-1)] + public void Validate_RetentionDays_OutOfRange_Fails(int value) + { + var opts = new KpiHistoryOptions { RetentionDays = value }; + var result = NewValidator().Validate(null, opts); + + Assert.False(result.Succeeded); + Assert.Contains( + result.Failures!, + f => f.Contains(nameof(KpiHistoryOptions.RetentionDays), StringComparison.Ordinal)); + } + + // --------------------------------------------------------------------- + // PurgeInterval > TimeSpan.Zero + // --------------------------------------------------------------------- + + [Fact] + public void Validate_PurgeInterval_Zero_Fails() + { + var opts = new KpiHistoryOptions { PurgeInterval = TimeSpan.Zero }; + var result = NewValidator().Validate(null, opts); + + Assert.False(result.Succeeded); + Assert.Contains( + result.Failures!, + f => f.Contains(nameof(KpiHistoryOptions.PurgeInterval), StringComparison.Ordinal)); + } + + [Fact] + public void Validate_PurgeInterval_Negative_Fails() + { + var opts = new KpiHistoryOptions { PurgeInterval = TimeSpan.FromHours(-1) }; + var result = NewValidator().Validate(null, opts); + + Assert.False(result.Succeeded); + Assert.Contains( + result.Failures!, + f => f.Contains(nameof(KpiHistoryOptions.PurgeInterval), StringComparison.Ordinal)); + } + + // --------------------------------------------------------------------- + // DefaultMaxSeriesPoints in [2, 5000] + // --------------------------------------------------------------------- + + [Theory] + [InlineData(2)] // min + [InlineData(200)] // default + [InlineData(5000)] // max + public void Validate_DefaultMaxSeriesPoints_InRange_Passes(int value) + { + var opts = new KpiHistoryOptions { DefaultMaxSeriesPoints = value }; + Assert.True(NewValidator().Validate(null, opts).Succeeded); + } + + [Theory] + [InlineData(1)] + [InlineData(5001)] + [InlineData(0)] + public void Validate_DefaultMaxSeriesPoints_OutOfRange_Fails(int value) + { + var opts = new KpiHistoryOptions { DefaultMaxSeriesPoints = value }; + var result = NewValidator().Validate(null, opts); + + Assert.False(result.Succeeded); + Assert.Contains( + result.Failures!, + f => f.Contains(nameof(KpiHistoryOptions.DefaultMaxSeriesPoints), StringComparison.Ordinal)); + } + + [Fact] + public void Validate_AllRulesViolated_AccumulatesEveryFailure() + { + // The base class aggregates ALL failures, not just the first — one + // message per violated rule. + var opts = new KpiHistoryOptions + { + SampleInterval = TimeSpan.Zero, + RetentionDays = 0, + PurgeInterval = TimeSpan.Zero, + DefaultMaxSeriesPoints = 1, + }; + var result = NewValidator().Validate(null, opts); + + Assert.False(result.Succeeded); + Assert.Equal(4, result.Failures!.Count()); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests.csproj b/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests.csproj new file mode 100644 index 00000000..99b85581 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + true + false + + + + + + + + + + + + + + + + + +