|
|
@@ -0,0 +1,369 @@
|
|
|
+using Admin.NET.Plugin.AiDOP.Controllers.S8;
|
|
|
+using Admin.NET.Plugin.AiDOP.Dto.S8;
|
|
|
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
|
|
|
+using System.Text.RegularExpressions;
|
|
|
+using Xunit;
|
|
|
+
|
|
|
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
|
|
|
+
|
|
|
+/// <summary>
|
|
|
+/// S8-DASHBOARD-P2-SEMANTICS-1:看板语义守卫(P2-B 今日 / P2-C 趋势桶 / P2-D 分母)。
|
|
|
+///
|
|
|
+/// <para><b>P2-C 的缺陷是一个纯函数缺陷</b>:<c>last_24h</c> 的窗口是
|
|
|
+/// <c>[Now-24h, Now)</c>(非零点),而日桶是按 <c>CreatedAt.Date</c>(零点)分组的,
|
|
|
+/// 原实现用 <c>from.AddDays(i)</c> 直接拿非零点的 <c>from</c> 当桶键去 <c>TryGetValue</c>,
|
|
|
+/// 永远 miss ⇒ 峰值 / 均值 / 今日 恒为 0。因此桶枚举被抽成
|
|
|
+/// <see cref="S8TrendBucketHelper"/> 后可以确定性验证,不依赖实库时钟。</para>
|
|
|
+///
|
|
|
+/// <para>服务层方法需要 SqlSugar 实库、进程内无法构造,故其余判据沿用本仓既有范式
|
|
|
+/// (反射元数据 + 去注释源码契约),与 <c>S8OrderArchiveAuthorityTests</c> 一致。</para>
|
|
|
+/// </summary>
|
|
|
+public class S8DashboardSemanticsTests
|
|
|
+{
|
|
|
+ private static string PluginRoot => Path.GetFullPath(
|
|
|
+ Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
|
|
|
+
|
|
|
+ private static string ReadCode(string relativePath)
|
|
|
+ {
|
|
|
+ var raw = File.ReadAllText(Path.Combine(PluginRoot, relativePath));
|
|
|
+ var noBlock = Regex.Replace(raw, @"/\*.*?\*/", string.Empty, RegexOptions.Singleline);
|
|
|
+ return Regex.Replace(noBlock, @"^\s*//.*$", string.Empty, RegexOptions.Multiline);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static string MonitoringCode() => ReadCode("Service/S8/S8MonitoringService.cs");
|
|
|
+ private static string KanbanCode() => ReadCode("Controllers/AidopKanbanController.cs");
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 取某个方法体:从签名起,到下一个「类成员级(4 空格缩进)声明」之前。
|
|
|
+ /// 必须把 private / internal 也算进终止符,否则方法体会吃进后面的私有辅助方法,
|
|
|
+ /// 让 <c>DoesNotContain</c> 类断言变成假失败。
|
|
|
+ /// </summary>
|
|
|
+ private static string MethodBody(string code, string signature)
|
|
|
+ {
|
|
|
+ var start = code.IndexOf(signature, StringComparison.Ordinal);
|
|
|
+ Assert.True(start > 0, $"未找到方法 {signature}");
|
|
|
+
|
|
|
+ var from = start + signature.Length;
|
|
|
+ var end = code.Length;
|
|
|
+ foreach (var kw in new[] { "\n public ", "\n private ", "\n internal ", "\n protected " })
|
|
|
+ {
|
|
|
+ var i = code.IndexOf(kw, from, StringComparison.Ordinal);
|
|
|
+ if (i > 0 && i < end) end = i;
|
|
|
+ }
|
|
|
+ return code[start..end];
|
|
|
+ }
|
|
|
+
|
|
|
+ // ══════════════ P2-C · 日桶枚举(纯函数,确定性) ══════════════
|
|
|
+
|
|
|
+ /// <summary>① today:窗口本就零点对齐,1 个桶,行为与历史一致。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void T01_DayBuckets_Today_SingleMidnightBucket()
|
|
|
+ {
|
|
|
+ var today = new DateTime(2026, 9, 22);
|
|
|
+ var plan = S8TrendBucketHelper.Resolve(today, today.AddDays(1));
|
|
|
+
|
|
|
+ Assert.Single(plan.Buckets);
|
|
|
+ Assert.Equal(today, plan.Buckets[0]);
|
|
|
+ Assert.False(plan.HasPartialEdge);
|
|
|
+ Assert.False(plan.IsTruncated);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>② last_24h:跨两个自然日,必须给出 2 个桶(原实现只给 1 个)。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void T02_DayBuckets_Last24h_SpansTwoCalendarDays()
|
|
|
+ {
|
|
|
+ var now = new DateTime(2026, 9, 22, 17, 25, 46);
|
|
|
+ var plan = S8TrendBucketHelper.Resolve(now.AddHours(-24), now);
|
|
|
+
|
|
|
+ Assert.Equal(2, plan.Buckets.Count);
|
|
|
+ Assert.Equal(new DateTime(2026, 9, 21), plan.Buckets[0]);
|
|
|
+ Assert.Equal(new DateTime(2026, 9, 22), plan.Buckets[1]);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>③ 桶键必须零点对齐——否则与 <c>GroupBy(r => r.CreatedAt.Date)</c> 永远对不上。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void T03_DayBuckets_KeysAreAlwaysMidnightAligned()
|
|
|
+ {
|
|
|
+ var now = new DateTime(2026, 9, 22, 17, 25, 46);
|
|
|
+ var plan = S8TrendBucketHelper.Resolve(now.AddHours(-24), now);
|
|
|
+
|
|
|
+ Assert.All(plan.Buckets, b => Assert.Equal(b.Date, b));
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>④ 桶必须覆盖整个查询窗口:Σ桶 == 窗口内行数。这是本次修复的核心不变量。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void T04_DayBuckets_CoverEveryRowInWindow()
|
|
|
+ {
|
|
|
+ var now = new DateTime(2026, 9, 22, 17, 25, 46);
|
|
|
+ var plan = S8TrendBucketHelper.Resolve(now.AddHours(-24), now);
|
|
|
+
|
|
|
+ // 构造分布在窗口内不同时刻的行(含昨日下午、今日凌晨、今日上午)
|
|
|
+ var rows = new[]
|
|
|
+ {
|
|
|
+ new DateTime(2026, 9, 21, 18, 0, 0),
|
|
|
+ new DateTime(2026, 9, 21, 23, 59, 59),
|
|
|
+ new DateTime(2026, 9, 22, 0, 0, 1),
|
|
|
+ new DateTime(2026, 9, 22, 12, 6, 44),
|
|
|
+ };
|
|
|
+ var inWindow = rows.Where(r => r >= plan.QueryFrom && r < plan.QueryToExclusive).ToList();
|
|
|
+ var byDate = inWindow.GroupBy(r => r.Date).ToDictionary(g => g.Key, g => g.Count());
|
|
|
+
|
|
|
+ var bucketed = plan.Buckets.Sum(b => byDate.TryGetValue(b, out var c) ? c : 0);
|
|
|
+
|
|
|
+ Assert.Equal(4, inWindow.Count);
|
|
|
+ Assert.Equal(inWindow.Count, bucketed);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>⑤ 原实现的反例固定下来:用非零点 <c>from</c> 当桶键,一行都命中不了。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void T05_DayBuckets_RegressionGuard_NonMidnightKeyMatchesNothing()
|
|
|
+ {
|
|
|
+ var now = new DateTime(2026, 9, 22, 17, 25, 46);
|
|
|
+ var from = now.AddHours(-24);
|
|
|
+ var byDate = new[] { new DateTime(2026, 9, 21, 18, 0, 0), new DateTime(2026, 9, 22, 9, 0, 0) }
|
|
|
+ .GroupBy(r => r.Date).ToDictionary(g => g.Key, g => g.Count());
|
|
|
+
|
|
|
+ // 旧写法:d = from.AddDays(i),from 带 17:25:46 → TryGetValue 必 miss
|
|
|
+ Assert.False(byDate.ContainsKey(from));
|
|
|
+ // 新写法:桶键取自 Resolve,必命中
|
|
|
+ var plan = S8TrendBucketHelper.Resolve(from, now);
|
|
|
+ Assert.Contains(plan.Buckets, b => byDate.ContainsKey(b));
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>⑥ last_7d / this_week 等零点对齐窗口行为不变(回归保护)。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void T06_DayBuckets_MidnightAlignedPeriods_Unchanged()
|
|
|
+ {
|
|
|
+ var today = new DateTime(2026, 9, 22);
|
|
|
+ var plan = S8TrendBucketHelper.Resolve(today.AddDays(-6), today.AddDays(1));
|
|
|
+
|
|
|
+ Assert.Equal(7, plan.Buckets.Count);
|
|
|
+ Assert.Equal(today.AddDays(-6), plan.Buckets[0]);
|
|
|
+ Assert.Equal(today, plan.Buckets[^1]);
|
|
|
+ Assert.False(plan.HasPartialEdge);
|
|
|
+ Assert.Equal(today.AddDays(-6), plan.QueryFrom);
|
|
|
+ Assert.Equal(today.AddDays(1), plan.QueryToExclusive);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>⑦ 超上限(如 92 天的季度)时保留最近 N 天,且查询窗口同步收紧,Σ桶仍等于行数。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void T07_DayBuckets_Truncation_KeepsLatestDaysAndNarrowsWindow()
|
|
|
+ {
|
|
|
+ var qStart = new DateTime(2026, 7, 1);
|
|
|
+ var qEnd = new DateTime(2026, 10, 1);
|
|
|
+ var plan = S8TrendBucketHelper.Resolve(qStart, qEnd);
|
|
|
+
|
|
|
+ Assert.Equal(S8TrendBucketHelper.MaxBuckets, plan.Buckets.Count);
|
|
|
+ Assert.True(plan.IsTruncated);
|
|
|
+ // 末桶必须是窗口最后一个自然日(而不是被砍掉尾巴)
|
|
|
+ Assert.Equal(new DateTime(2026, 9, 30), plan.Buckets[^1]);
|
|
|
+ // 查询窗口与桶范围一致,绝不查出桶外的行
|
|
|
+ Assert.Equal(plan.Buckets[0], plan.QueryFrom);
|
|
|
+ Assert.Equal(plan.Buckets[^1].AddDays(1), plan.QueryToExclusive);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>⑧ 查询窗口只能收紧、不得放宽(防止桶对齐把窗口撑大而多统计行)。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void T08_DayBuckets_QueryWindow_NeverWidensBeyondInput()
|
|
|
+ {
|
|
|
+ var now = new DateTime(2026, 9, 22, 17, 25, 46);
|
|
|
+ var from = now.AddHours(-24);
|
|
|
+ var plan = S8TrendBucketHelper.Resolve(from, now);
|
|
|
+
|
|
|
+ Assert.True(plan.QueryFrom >= from);
|
|
|
+ Assert.True(plan.QueryToExclusive <= now);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>⑨ 边界不完整时必须自报(供上层抑制「今日 vs 昨日」环比)。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void T09_DayBuckets_PartialEdge_IsReported()
|
|
|
+ {
|
|
|
+ var now = new DateTime(2026, 9, 22, 17, 25, 46);
|
|
|
+ Assert.True(S8TrendBucketHelper.Resolve(now.AddHours(-24), now).HasPartialEdge);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>⑩ 空窗 / 倒挂窗不得抛异常,至少给 1 个桶。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void T10_DayBuckets_DegenerateWindow_IsSafe()
|
|
|
+ {
|
|
|
+ var t = new DateTime(2026, 9, 22, 10, 0, 0);
|
|
|
+ var plan = S8TrendBucketHelper.Resolve(t, t);
|
|
|
+
|
|
|
+ Assert.Single(plan.Buckets);
|
|
|
+ Assert.Equal(t.Date, plan.Buckets[0]);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ══════════════ P2-C · 三条趋势必须走同一枚举 ══════════════
|
|
|
+
|
|
|
+ /// <summary>⑪ 三条趋势方法都必须改用共享桶枚举。</summary>
|
|
|
+ [Theory]
|
|
|
+ [InlineData("GetDeliveryTrendAsync")]
|
|
|
+ [InlineData("GetProductionTrendAsync")]
|
|
|
+ [InlineData("GetSupplyTrendAsync")]
|
|
|
+ public void T11_TrendMethods_UseSharedBucketPlan(string method)
|
|
|
+ {
|
|
|
+ var body = MethodBody(MonitoringCode(), $"public async Task<Ado{(method.Contains("Delivery") ? "S8Delivery" : method.Contains("Production") ? "S8Production" : "S8Supply")}TrendDto> {method}");
|
|
|
+
|
|
|
+ Assert.Contains("ResolveTrendWindow", body);
|
|
|
+ Assert.Contains("plan.Buckets", body);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>⑫ 不得再出现「用 from 自增当桶键」的旧写法。</summary>
|
|
|
+ [Theory]
|
|
|
+ [InlineData("GetDeliveryTrendAsync")]
|
|
|
+ [InlineData("GetProductionTrendAsync")]
|
|
|
+ [InlineData("GetSupplyTrendAsync")]
|
|
|
+ public void T12_TrendMethods_DropRawFromAddDaysBucketKey(string method)
|
|
|
+ {
|
|
|
+ var body = MethodBody(MonitoringCode(), $"public async Task<Ado{(method.Contains("Delivery") ? "S8Delivery" : method.Contains("Production") ? "S8Production" : "S8Supply")}TrendDto> {method}");
|
|
|
+
|
|
|
+ Assert.DoesNotContain("var d = from.AddDays(i)", body);
|
|
|
+ Assert.DoesNotContain("Math.Min(90, (int)(toExclusive - from).TotalDays)", body);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>⑬ 三条趋势的摘要必须走同一个构建器(口径不得各写各的)。</summary>
|
|
|
+ [Theory]
|
|
|
+ [InlineData("GetDeliveryTrendAsync")]
|
|
|
+ [InlineData("GetProductionTrendAsync")]
|
|
|
+ [InlineData("GetSupplyTrendAsync")]
|
|
|
+ public void T13_TrendMethods_ShareSummaryBuilder(string method)
|
|
|
+ {
|
|
|
+ var body = MethodBody(MonitoringCode(), $"public async Task<Ado{(method.Contains("Delivery") ? "S8Delivery" : method.Contains("Production") ? "S8Production" : "S8Supply")}TrendDto> {method}");
|
|
|
+
|
|
|
+ Assert.Contains("BuildTrendSummary(plan,", body);
|
|
|
+ // 不得再各自手搓环比
|
|
|
+ Assert.DoesNotContain("changeRate", body);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>⑬b 边界桶不完整时,日环比必须置 null(不得拿半天比全天)。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void T13b_TrendSummary_SuppressesChangeRateOnPartialEdge()
|
|
|
+ {
|
|
|
+ var code = MonitoringCode();
|
|
|
+ var start = code.IndexOf("private static AdoS8DeliveryTrendSummaryDto BuildTrendSummary", StringComparison.Ordinal);
|
|
|
+ Assert.True(start > 0, "未找到 BuildTrendSummary");
|
|
|
+ var body = code[start..];
|
|
|
+
|
|
|
+ Assert.Contains("plan.HasPartialEdge", body);
|
|
|
+ Assert.Contains("PartialEdgeDays = plan.HasPartialEdge", body);
|
|
|
+ Assert.Contains("WindowTruncated = plan.IsTruncated", body);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>⑭ 趋势出参必须自带统计窗口,口径可被前端与审计核对。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void T14_TrendSummaryDto_ExposesWindow()
|
|
|
+ {
|
|
|
+ var t = typeof(AdoS8DeliveryTrendSummaryDto);
|
|
|
+
|
|
|
+ Assert.NotNull(t.GetProperty("WindowFrom"));
|
|
|
+ Assert.NotNull(t.GetProperty("WindowToExclusive"));
|
|
|
+ Assert.NotNull(t.GetProperty("WindowTruncated"));
|
|
|
+ Assert.NotNull(t.GetProperty("PartialEdgeDays"));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ══════════════ P2-D · 每百订单分母 ══════════════
|
|
|
+
|
|
|
+ /// <summary>⑮ 无分母 Authority ⇒ Frequency 必须可空,用 null 表达 NO_DATA。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void T15_Frequency_IsNullable_ForNoData()
|
|
|
+ {
|
|
|
+ var p = typeof(AdoS8ModuleOrderSummary).GetProperty("Frequency");
|
|
|
+ Assert.NotNull(p);
|
|
|
+ Assert.Equal(typeof(double?), p!.PropertyType);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>⑯ 绝不能再把「异常绝对数」当成「每百订单异常数」发出去。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void T16_Frequency_IsNotAssignedRawTotal()
|
|
|
+ {
|
|
|
+ var code = MonitoringCode();
|
|
|
+
|
|
|
+ Assert.DoesNotContain("Frequency = total,", code);
|
|
|
+ Assert.DoesNotContain("Frequency = total,", code);
|
|
|
+ Assert.Contains("Frequency = null,", code);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>⑰ 不得为了让卡片活着而自造分母(拿销售订单数去除以采购异常数等)。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void T17_Frequency_DoesNotInventDenominator()
|
|
|
+ {
|
|
|
+ var body = MethodBody(MonitoringCode(), "public async Task<AdoS8OrderGridDto> GetOrderGridAsync");
|
|
|
+
|
|
|
+ Assert.DoesNotContain("mdp_std_so", body);
|
|
|
+ Assert.DoesNotContain("* 100.0 / orderCount", body);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ══════════════ P2-B · 今日预警数 ══════════════
|
|
|
+
|
|
|
+ /// <summary>⑱ 必须存在一个真正做计数的汇总端点(列表端点的 LIMIT 不能当计数)。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void T18_AlertSummary_EndpointExists()
|
|
|
+ {
|
|
|
+ Assert.Contains("[HttpGet(\"s8-alert-summary\")]", KanbanCode());
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>⑲ 汇总端点必须 COUNT,且不得带 LIMIT。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void T19_AlertSummary_CountsWithoutLimit()
|
|
|
+ {
|
|
|
+ var body = MethodBody(KanbanCode(), "public async Task<IActionResult> GetS8AlertSummary");
|
|
|
+
|
|
|
+ Assert.Contains("COUNT(*)", body);
|
|
|
+ Assert.DoesNotContain("LIMIT", body);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>⑳ 「今日」必须是显式的服务端自然日窗口,不是「最近 N 条」的副作用。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void T20_AlertSummary_HasExplicitTodayWindow()
|
|
|
+ {
|
|
|
+ var body = MethodBody(KanbanCode(), "public async Task<IActionResult> GetS8AlertSummary");
|
|
|
+
|
|
|
+ Assert.Contains("DateTime.Today", body);
|
|
|
+ Assert.Contains("windowStart", body);
|
|
|
+ Assert.Contains("windowEnd", body);
|
|
|
+ Assert.Contains("isToday", body);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>㉑ 汇总端点必须租户 + 工厂作用域,且窗口是半开区间。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void T21_AlertSummary_IsTenantScoped()
|
|
|
+ {
|
|
|
+ var body = MethodBody(KanbanCode(), "public async Task<IActionResult> GetS8AlertSummary");
|
|
|
+
|
|
|
+ Assert.Contains("AidopTenantHelper.GetTenantId(HttpContext)", body);
|
|
|
+ Assert.Contains("tenant_id=@tenantId", body);
|
|
|
+ Assert.Contains("factory_id=@factoryId", body);
|
|
|
+ Assert.Contains("alert_time >= @ws", body);
|
|
|
+ Assert.Contains("alert_time < @we", body);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>㉒ 汇总端点必须复用同一套维度过滤,不得另起一套口径。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void T22_AlertSummary_ReusesSharedDimensionFilter()
|
|
|
+ {
|
|
|
+ var body = MethodBody(KanbanCode(), "public async Task<IActionResult> GetS8AlertSummary");
|
|
|
+
|
|
|
+ Assert.Contains("BuildS8AlertTextFilterSql()", body);
|
|
|
+ Assert.Contains("SmartOpsDashboardFilter.FromQuery", body);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>㉓ 既有 s8-alerts 列表端点不得被本批改动(home.vue 仍依赖它返回最近 6 条)。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void T23_LegacyAlertsEndpoint_StillReturnsRecentList()
|
|
|
+ {
|
|
|
+ var body = MethodBody(KanbanCode(), "public async Task<IActionResult> GetS8Alerts");
|
|
|
+
|
|
|
+ Assert.Contains("LIMIT 6", body);
|
|
|
+ Assert.Contains("ORDER BY alert_time DESC", body);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>㉔ 汇总端点必须同时给出严重级计数,避免前端再拿 6 条列表推严重数。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void T24_AlertSummary_ReturnsCriticalCount()
|
|
|
+ {
|
|
|
+ var body = MethodBody(KanbanCode(), "public async Task<IActionResult> GetS8AlertSummary");
|
|
|
+
|
|
|
+ Assert.Contains("critical", body);
|
|
|
+ Assert.Contains("level_code", body);
|
|
|
+ }
|
|
|
+}
|