using Admin.NET.Plugin.AiDOP.Entity; using Admin.NET.Plugin.AiDOP.SmartOps; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.SmartOps; public class S1KpiTargetSnapshotRefreshServiceTests { private static S1KpiTargetSnapshotRefreshService Svc(FakeS1KpiDailySnapshotStore daily, FakeKpiTargetStore configs) => new(daily, new KpiTargetResolver(configs, new LegacyKpiTargetProvider(new FakeDailyReader(), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance)); [Fact] public async Task FormalFactory_OverwritesLegacySnapshot_OnMaxBizDateOnly() { var daily = new FakeS1KpiDailySnapshotStore(); daily.Add(1, 9, 1, new DateTime(2026, 8, 13), "S1_L1_001", 20.3m, 20m); daily.Add(1, 9, 1, new DateTime(2026, 8, 14), "S1_L1_001", 20.3m, 20m); daily.Add(2, 9, 1, new DateTime(2026, 8, 14), "S1_L2_001", 1m, 3m); var configs = new FakeKpiTargetStore(); configs.Configs.Add(Cfg(11, 1, 10m, new DateTime(2026, 8, 14), metricCode: "S1_L1_001")); configs.Configs.Add(Cfg(12, 1, 10m, new DateTime(2026, 8, 14), metricCode: "S1_L2_001")); var result = await Svc(daily, configs).RefreshAsync(9, 1); Assert.True(result.Ok); Assert.Equal(2, result.UpdatedRows); Assert.Equal(10m, daily.Get(1, 9, 1, new DateTime(2026, 8, 14), "S1_L1_001").TargetValue); Assert.Equal(KpiTargetSnapshotSource.FormalFactory, daily.Get(1, 9, 1, new DateTime(2026, 8, 14), "S1_L1_001").TargetSource); Assert.Equal(11, daily.Get(1, 9, 1, new DateTime(2026, 8, 14), "S1_L1_001").TargetConfigId); Assert.Equal(20m, daily.Get(1, 9, 1, new DateTime(2026, 8, 13), "S1_L1_001").TargetValue); Assert.Equal(20.3m, daily.Get(1, 9, 1, new DateTime(2026, 8, 14), "S1_L1_001").MetricValue); Assert.Equal(10m, daily.Get(2, 9, 1, new DateTime(2026, 8, 14), "S1_L2_001").TargetValue); } [Fact] public async Task TenantDefault_UsedWhenFactoryMissing() { var daily = new FakeS1KpiDailySnapshotStore(); daily.Add(1, 9, 1, DateTime.Today, "S1_L1_001", 8m, 20m); var configs = new FakeKpiTargetStore(); configs.Configs.Add(Cfg(7, 0, 5m, DateTime.Today.AddDays(-10), metricCode: "S1_L1_001")); var result = await Svc(daily, configs).RefreshAsync(9, 1); Assert.Equal(1, result.UpdatedRows); Assert.Equal(5m, daily.Get(1, 9, 1, DateTime.Today, "S1_L1_001").TargetValue); Assert.Equal(KpiTargetSnapshotSource.FormalTenantDefault, daily.Get(1, 9, 1, DateTime.Today, "S1_L1_001").TargetSource); } [Fact] public async Task Missing_DoesNotOverwrite() { var daily = new FakeS1KpiDailySnapshotStore(); daily.Add(1, 9, 1, DateTime.Today, "S5_L1_001", 8m, 20m); var result = await Svc(daily, new FakeKpiTargetStore()).RefreshAsync(9, 1); Assert.Equal(0, result.UpdatedRows); Assert.True(result.MissingRows >= 1); Assert.Equal(20m, daily.Get(1, 9, 1, DateTime.Today, "S5_L1_001").TargetValue); Assert.Null(daily.Get(1, 9, 1, DateTime.Today, "S5_L1_001").TargetSource); } [Fact] public async Task DoesNotTouchOtherTenantOrFactory() { var daily = new FakeS1KpiDailySnapshotStore(); daily.Add(1, 9, 1, DateTime.Today, "S1_L1_001", 8m, 20m); daily.Add(1, 8, 1, DateTime.Today, "S1_L1_001", 8m, 20m); daily.Add(1, 9, 2, DateTime.Today, "S1_L1_001", 8m, 20m); var configs = new FakeKpiTargetStore(); configs.Configs.Add(Cfg(1, 1, 10m, DateTime.Today.AddDays(-1), metricCode: "S1_L1_001")); await Svc(daily, configs).RefreshAsync(9, 1); Assert.Equal(10m, daily.Get(1, 9, 1, DateTime.Today, "S1_L1_001").TargetValue); Assert.Equal(20m, daily.Get(1, 8, 1, DateTime.Today, "S1_L1_001").TargetValue); Assert.Equal(20m, daily.Get(1, 9, 2, DateTime.Today, "S1_L1_001").TargetValue); } [Fact] public async Task LevelFailure_RollsBackAllLevels() { var daily = new FakeS1KpiDailySnapshotStore { FailOnLevel = 3 }; daily.Add(1, 9, 1, DateTime.Today, "S1_L1_001", 8m, 20m); daily.Add(3, 9, 1, DateTime.Today, "S1_L3_001", 8m, 20m); var configs = new FakeKpiTargetStore(); configs.Configs.Add(Cfg(1, 1, 10m, DateTime.Today.AddDays(-1), metricCode: "S1_L1_001")); configs.Configs.Add(Cfg(2, 1, 10m, DateTime.Today.AddDays(-1), metricCode: "S1_L3_001")); await Assert.ThrowsAsync(() => Svc(daily, configs).RefreshAsync(9, 1)); Assert.Equal(20m, daily.Get(1, 9, 1, DateTime.Today, "S1_L1_001").TargetValue); } [Fact] public async Task NoDailyRows_ReturnsZero() { var result = await Svc(new FakeS1KpiDailySnapshotStore(), new FakeKpiTargetStore()).RefreshAsync(9, 1); Assert.True(result.Ok); Assert.Equal(0, result.UpdatedRows); Assert.Contains("暂无", result.Message); } private static AdoSmartOpsKpiTargetConfig Cfg(long id, long factoryId, decimal value, DateTime from, DateTime? to = null, string metricCode = "S1_L1_001") => new() { Id = id, TenantId = 9, FactoryId = factoryId, MetricCode = metricCode, TargetValue = value, EffectiveFrom = from, EffectiveTo = to, SourceType = "MANUAL", Status = 1, CreateTime = DateTime.Now, UpdateTime = DateTime.Now }; } internal sealed class FakeS1KpiDailySnapshotStore : IS1KpiDailySnapshotStore { public int? FailOnLevel { get; set; } private readonly List _rows = new(); public void Add(int level, long tenantId, long factoryId, DateTime bizDate, string metric, decimal metricValue, decimal? target) => _rows.Add(new Row { Level = level, TenantId = tenantId, FactoryId = factoryId, BizDate = bizDate.Date, MetricCode = metric, MetricValue = metricValue, TargetValue = target }); public Row Get(int level, long tenantId, long factoryId, DateTime bizDate, string metric) => _rows.Single(x => x.Level == level && x.TenantId == tenantId && x.FactoryId == factoryId && x.BizDate == bizDate.Date && x.MetricCode == metric); public async Task ExecuteInTransactionAsync(Func action, CancellationToken ct = default) { var backup = _rows.Select(x => x.Clone()).ToList(); try { await action(); } catch { _rows.Clear(); _rows.AddRange(backup); throw; } } public Task GetMaxBizDateAsync(int level, long tenantId, long factoryId, string moduleCode, CancellationToken ct = default) { _ = moduleCode; var dates = _rows.Where(x => x.Level == level && x.TenantId == tenantId && x.FactoryId == factoryId).Select(x => x.BizDate).ToList(); DateTime? max = dates.Count == 0 ? null : dates.Max(); return Task.FromResult(max); } public Task> ListLatestAsync(int level, long tenantId, long factoryId, DateTime bizDate, string moduleCode, CancellationToken ct = default) { _ = moduleCode; return Task.FromResult(_rows.Where(x => x.Level == level && x.TenantId == tenantId && x.FactoryId == factoryId && x.BizDate == bizDate.Date) .Select(x => new S1KpiDailySnapshotRow { MetricCode = x.MetricCode, BizDate = x.BizDate, MetricValue = x.MetricValue }) .ToList()); } public Task UpdateTargetsAsync(int level, long tenantId, long factoryId, DateTime bizDate, string metricCode, KpiTargetSnapshot snap, string moduleCode, CancellationToken ct = default) { if (FailOnLevel == level) throw new InvalidOperationException($"fail level {level}"); var hits = _rows.Where(x => x.Level == level && x.TenantId == tenantId && x.FactoryId == factoryId && x.BizDate == bizDate.Date && x.MetricCode == metricCode).ToList(); foreach (var row in hits) { row.TargetValue = snap.TargetValue; row.TargetConfigId = snap.TargetConfigId; row.TargetSource = snap.TargetSource; row.TargetResolvedAt = snap.ResolvedAt; } return Task.FromResult(hits.Count); } internal sealed class Row { public int Level { get; set; } public long TenantId { get; set; } public long FactoryId { get; set; } public DateTime BizDate { get; set; } public string MetricCode { get; set; } = string.Empty; public decimal? MetricValue { get; set; } public decimal? TargetValue { get; set; } public long? TargetConfigId { get; set; } public string TargetSource { get; set; } public DateTime? TargetResolvedAt { get; set; } public Row Clone() => (Row)MemberwiseClone(); } }