S8DashboardSemanticsTests.cs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  1. using Admin.NET.Plugin.AiDOP.Controllers.S8;
  2. using Admin.NET.Plugin.AiDOP.Dto.S8;
  3. using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  4. using System.Text.RegularExpressions;
  5. using Xunit;
  6. namespace Admin.NET.Plugin.AiDOP.Tests.S8;
  7. /// <summary>
  8. /// S8-DASHBOARD-P2-SEMANTICS-1:看板语义守卫(P2-B 今日 / P2-C 趋势桶 / P2-D 分母)。
  9. ///
  10. /// <para><b>P2-C 的缺陷是一个纯函数缺陷</b>:<c>last_24h</c> 的窗口是
  11. /// <c>[Now-24h, Now)</c>(非零点),而日桶是按 <c>CreatedAt.Date</c>(零点)分组的,
  12. /// 原实现用 <c>from.AddDays(i)</c> 直接拿非零点的 <c>from</c> 当桶键去 <c>TryGetValue</c>,
  13. /// 永远 miss ⇒ 峰值 / 均值 / 今日 恒为 0。因此桶枚举被抽成
  14. /// <see cref="S8TrendBucketHelper"/> 后可以确定性验证,不依赖实库时钟。</para>
  15. ///
  16. /// <para>服务层方法需要 SqlSugar 实库、进程内无法构造,故其余判据沿用本仓既有范式
  17. /// (反射元数据 + 去注释源码契约),与 <c>S8OrderArchiveAuthorityTests</c> 一致。</para>
  18. /// </summary>
  19. public class S8DashboardSemanticsTests
  20. {
  21. private static string PluginRoot => Path.GetFullPath(
  22. Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
  23. private static string ReadCode(string relativePath)
  24. {
  25. var raw = File.ReadAllText(Path.Combine(PluginRoot, relativePath));
  26. var noBlock = Regex.Replace(raw, @"/\*.*?\*/", string.Empty, RegexOptions.Singleline);
  27. return Regex.Replace(noBlock, @"^\s*//.*$", string.Empty, RegexOptions.Multiline);
  28. }
  29. private static string MonitoringCode() => ReadCode("Service/S8/S8MonitoringService.cs");
  30. private static string KanbanCode() => ReadCode("Controllers/AidopKanbanController.cs");
  31. /// <summary>
  32. /// 取某个方法体:从签名起,到下一个「类成员级(4 空格缩进)声明」之前。
  33. /// 必须把 private / internal 也算进终止符,否则方法体会吃进后面的私有辅助方法,
  34. /// 让 <c>DoesNotContain</c> 类断言变成假失败。
  35. /// </summary>
  36. private static string MethodBody(string code, string signature)
  37. {
  38. var start = code.IndexOf(signature, StringComparison.Ordinal);
  39. Assert.True(start > 0, $"未找到方法 {signature}");
  40. var from = start + signature.Length;
  41. var end = code.Length;
  42. foreach (var kw in new[] { "\n public ", "\n private ", "\n internal ", "\n protected " })
  43. {
  44. var i = code.IndexOf(kw, from, StringComparison.Ordinal);
  45. if (i > 0 && i < end) end = i;
  46. }
  47. return code[start..end];
  48. }
  49. // ══════════════ P2-C · 日桶枚举(纯函数,确定性) ══════════════
  50. /// <summary>① today:窗口本就零点对齐,1 个桶,行为与历史一致。</summary>
  51. [Fact]
  52. public void T01_DayBuckets_Today_SingleMidnightBucket()
  53. {
  54. var today = new DateTime(2026, 9, 22);
  55. var plan = S8TrendBucketHelper.Resolve(today, today.AddDays(1));
  56. Assert.Single(plan.Buckets);
  57. Assert.Equal(today, plan.Buckets[0]);
  58. Assert.False(plan.HasPartialEdge);
  59. Assert.False(plan.IsTruncated);
  60. }
  61. /// <summary>② last_24h:跨两个自然日,必须给出 2 个桶(原实现只给 1 个)。</summary>
  62. [Fact]
  63. public void T02_DayBuckets_Last24h_SpansTwoCalendarDays()
  64. {
  65. var now = new DateTime(2026, 9, 22, 17, 25, 46);
  66. var plan = S8TrendBucketHelper.Resolve(now.AddHours(-24), now);
  67. Assert.Equal(2, plan.Buckets.Count);
  68. Assert.Equal(new DateTime(2026, 9, 21), plan.Buckets[0]);
  69. Assert.Equal(new DateTime(2026, 9, 22), plan.Buckets[1]);
  70. }
  71. /// <summary>③ 桶键必须零点对齐——否则与 <c>GroupBy(r =&gt; r.CreatedAt.Date)</c> 永远对不上。</summary>
  72. [Fact]
  73. public void T03_DayBuckets_KeysAreAlwaysMidnightAligned()
  74. {
  75. var now = new DateTime(2026, 9, 22, 17, 25, 46);
  76. var plan = S8TrendBucketHelper.Resolve(now.AddHours(-24), now);
  77. Assert.All(plan.Buckets, b => Assert.Equal(b.Date, b));
  78. }
  79. /// <summary>④ 桶必须覆盖整个查询窗口:Σ桶 == 窗口内行数。这是本次修复的核心不变量。</summary>
  80. [Fact]
  81. public void T04_DayBuckets_CoverEveryRowInWindow()
  82. {
  83. var now = new DateTime(2026, 9, 22, 17, 25, 46);
  84. var plan = S8TrendBucketHelper.Resolve(now.AddHours(-24), now);
  85. // 构造分布在窗口内不同时刻的行(含昨日下午、今日凌晨、今日上午)
  86. var rows = new[]
  87. {
  88. new DateTime(2026, 9, 21, 18, 0, 0),
  89. new DateTime(2026, 9, 21, 23, 59, 59),
  90. new DateTime(2026, 9, 22, 0, 0, 1),
  91. new DateTime(2026, 9, 22, 12, 6, 44),
  92. };
  93. var inWindow = rows.Where(r => r >= plan.QueryFrom && r < plan.QueryToExclusive).ToList();
  94. var byDate = inWindow.GroupBy(r => r.Date).ToDictionary(g => g.Key, g => g.Count());
  95. var bucketed = plan.Buckets.Sum(b => byDate.TryGetValue(b, out var c) ? c : 0);
  96. Assert.Equal(4, inWindow.Count);
  97. Assert.Equal(inWindow.Count, bucketed);
  98. }
  99. /// <summary>⑤ 原实现的反例固定下来:用非零点 <c>from</c> 当桶键,一行都命中不了。</summary>
  100. [Fact]
  101. public void T05_DayBuckets_RegressionGuard_NonMidnightKeyMatchesNothing()
  102. {
  103. var now = new DateTime(2026, 9, 22, 17, 25, 46);
  104. var from = now.AddHours(-24);
  105. var byDate = new[] { new DateTime(2026, 9, 21, 18, 0, 0), new DateTime(2026, 9, 22, 9, 0, 0) }
  106. .GroupBy(r => r.Date).ToDictionary(g => g.Key, g => g.Count());
  107. // 旧写法:d = from.AddDays(i),from 带 17:25:46 → TryGetValue 必 miss
  108. Assert.False(byDate.ContainsKey(from));
  109. // 新写法:桶键取自 Resolve,必命中
  110. var plan = S8TrendBucketHelper.Resolve(from, now);
  111. Assert.Contains(plan.Buckets, b => byDate.ContainsKey(b));
  112. }
  113. /// <summary>⑥ last_7d / this_week 等零点对齐窗口行为不变(回归保护)。</summary>
  114. [Fact]
  115. public void T06_DayBuckets_MidnightAlignedPeriods_Unchanged()
  116. {
  117. var today = new DateTime(2026, 9, 22);
  118. var plan = S8TrendBucketHelper.Resolve(today.AddDays(-6), today.AddDays(1));
  119. Assert.Equal(7, plan.Buckets.Count);
  120. Assert.Equal(today.AddDays(-6), plan.Buckets[0]);
  121. Assert.Equal(today, plan.Buckets[^1]);
  122. Assert.False(plan.HasPartialEdge);
  123. Assert.Equal(today.AddDays(-6), plan.QueryFrom);
  124. Assert.Equal(today.AddDays(1), plan.QueryToExclusive);
  125. }
  126. /// <summary>⑦ 超上限(如 92 天的季度)时保留最近 N 天,且查询窗口同步收紧,Σ桶仍等于行数。</summary>
  127. [Fact]
  128. public void T07_DayBuckets_Truncation_KeepsLatestDaysAndNarrowsWindow()
  129. {
  130. var qStart = new DateTime(2026, 7, 1);
  131. var qEnd = new DateTime(2026, 10, 1);
  132. var plan = S8TrendBucketHelper.Resolve(qStart, qEnd);
  133. Assert.Equal(S8TrendBucketHelper.MaxBuckets, plan.Buckets.Count);
  134. Assert.True(plan.IsTruncated);
  135. // 末桶必须是窗口最后一个自然日(而不是被砍掉尾巴)
  136. Assert.Equal(new DateTime(2026, 9, 30), plan.Buckets[^1]);
  137. // 查询窗口与桶范围一致,绝不查出桶外的行
  138. Assert.Equal(plan.Buckets[0], plan.QueryFrom);
  139. Assert.Equal(plan.Buckets[^1].AddDays(1), plan.QueryToExclusive);
  140. }
  141. /// <summary>⑧ 查询窗口只能收紧、不得放宽(防止桶对齐把窗口撑大而多统计行)。</summary>
  142. [Fact]
  143. public void T08_DayBuckets_QueryWindow_NeverWidensBeyondInput()
  144. {
  145. var now = new DateTime(2026, 9, 22, 17, 25, 46);
  146. var from = now.AddHours(-24);
  147. var plan = S8TrendBucketHelper.Resolve(from, now);
  148. Assert.True(plan.QueryFrom >= from);
  149. Assert.True(plan.QueryToExclusive <= now);
  150. }
  151. /// <summary>⑨ 边界不完整时必须自报(供上层抑制「今日 vs 昨日」环比)。</summary>
  152. [Fact]
  153. public void T09_DayBuckets_PartialEdge_IsReported()
  154. {
  155. var now = new DateTime(2026, 9, 22, 17, 25, 46);
  156. Assert.True(S8TrendBucketHelper.Resolve(now.AddHours(-24), now).HasPartialEdge);
  157. }
  158. /// <summary>⑩ 空窗 / 倒挂窗不得抛异常,至少给 1 个桶。</summary>
  159. [Fact]
  160. public void T10_DayBuckets_DegenerateWindow_IsSafe()
  161. {
  162. var t = new DateTime(2026, 9, 22, 10, 0, 0);
  163. var plan = S8TrendBucketHelper.Resolve(t, t);
  164. Assert.Single(plan.Buckets);
  165. Assert.Equal(t.Date, plan.Buckets[0]);
  166. }
  167. // ══════════════ P2-C · 三条趋势必须走同一枚举 ══════════════
  168. /// <summary>⑪ 三条趋势方法都必须改用共享桶枚举。</summary>
  169. [Theory]
  170. [InlineData("GetDeliveryTrendAsync")]
  171. [InlineData("GetProductionTrendAsync")]
  172. [InlineData("GetSupplyTrendAsync")]
  173. public void T11_TrendMethods_UseSharedBucketPlan(string method)
  174. {
  175. var body = MethodBody(MonitoringCode(), $"public async Task<Ado{(method.Contains("Delivery") ? "S8Delivery" : method.Contains("Production") ? "S8Production" : "S8Supply")}TrendDto> {method}");
  176. Assert.Contains("ResolveTrendWindow", body);
  177. Assert.Contains("plan.Buckets", body);
  178. }
  179. /// <summary>⑫ 不得再出现「用 from 自增当桶键」的旧写法。</summary>
  180. [Theory]
  181. [InlineData("GetDeliveryTrendAsync")]
  182. [InlineData("GetProductionTrendAsync")]
  183. [InlineData("GetSupplyTrendAsync")]
  184. public void T12_TrendMethods_DropRawFromAddDaysBucketKey(string method)
  185. {
  186. var body = MethodBody(MonitoringCode(), $"public async Task<Ado{(method.Contains("Delivery") ? "S8Delivery" : method.Contains("Production") ? "S8Production" : "S8Supply")}TrendDto> {method}");
  187. Assert.DoesNotContain("var d = from.AddDays(i)", body);
  188. Assert.DoesNotContain("Math.Min(90, (int)(toExclusive - from).TotalDays)", body);
  189. }
  190. /// <summary>⑬ 三条趋势的摘要必须走同一个构建器(口径不得各写各的)。</summary>
  191. [Theory]
  192. [InlineData("GetDeliveryTrendAsync")]
  193. [InlineData("GetProductionTrendAsync")]
  194. [InlineData("GetSupplyTrendAsync")]
  195. public void T13_TrendMethods_ShareSummaryBuilder(string method)
  196. {
  197. var body = MethodBody(MonitoringCode(), $"public async Task<Ado{(method.Contains("Delivery") ? "S8Delivery" : method.Contains("Production") ? "S8Production" : "S8Supply")}TrendDto> {method}");
  198. Assert.Contains("BuildTrendSummary(plan,", body);
  199. // 不得再各自手搓环比
  200. Assert.DoesNotContain("changeRate", body);
  201. }
  202. /// <summary>⑬b 边界桶不完整时,日环比必须置 null(不得拿半天比全天)。</summary>
  203. [Fact]
  204. public void T13b_TrendSummary_SuppressesChangeRateOnPartialEdge()
  205. {
  206. var code = MonitoringCode();
  207. var start = code.IndexOf("private static AdoS8DeliveryTrendSummaryDto BuildTrendSummary", StringComparison.Ordinal);
  208. Assert.True(start > 0, "未找到 BuildTrendSummary");
  209. var body = code[start..];
  210. Assert.Contains("plan.HasPartialEdge", body);
  211. Assert.Contains("PartialEdgeDays = plan.HasPartialEdge", body);
  212. Assert.Contains("WindowTruncated = plan.IsTruncated", body);
  213. }
  214. /// <summary>⑭ 趋势出参必须自带统计窗口,口径可被前端与审计核对。</summary>
  215. [Fact]
  216. public void T14_TrendSummaryDto_ExposesWindow()
  217. {
  218. var t = typeof(AdoS8DeliveryTrendSummaryDto);
  219. Assert.NotNull(t.GetProperty("WindowFrom"));
  220. Assert.NotNull(t.GetProperty("WindowToExclusive"));
  221. Assert.NotNull(t.GetProperty("WindowTruncated"));
  222. Assert.NotNull(t.GetProperty("PartialEdgeDays"));
  223. }
  224. // ══════════════ P2-D · 每百订单分母 ══════════════
  225. /// <summary>⑮ 无分母 Authority ⇒ Frequency 必须可空,用 null 表达 NO_DATA。</summary>
  226. [Fact]
  227. public void T15_Frequency_IsNullable_ForNoData()
  228. {
  229. var p = typeof(AdoS8ModuleOrderSummary).GetProperty("Frequency");
  230. Assert.NotNull(p);
  231. Assert.Equal(typeof(double?), p!.PropertyType);
  232. }
  233. /// <summary>⑯ 绝不能再把「异常绝对数」当成「每百订单异常数」发出去。</summary>
  234. [Fact]
  235. public void T16_Frequency_IsNotAssignedRawTotal()
  236. {
  237. var code = MonitoringCode();
  238. Assert.DoesNotContain("Frequency = total,", code);
  239. Assert.DoesNotContain("Frequency = total,", code);
  240. Assert.Contains("Frequency = null,", code);
  241. }
  242. /// <summary>⑰ 不得为了让卡片活着而自造分母(拿销售订单数去除以采购异常数等)。</summary>
  243. [Fact]
  244. public void T17_Frequency_DoesNotInventDenominator()
  245. {
  246. var body = MethodBody(MonitoringCode(), "public async Task<AdoS8OrderGridDto> GetOrderGridAsync");
  247. Assert.DoesNotContain("mdp_std_so", body);
  248. Assert.DoesNotContain("* 100.0 / orderCount", body);
  249. }
  250. // ══════════════ P2-B · 今日预警数 ══════════════
  251. /// <summary>⑱ 必须存在一个真正做计数的汇总端点(列表端点的 LIMIT 不能当计数)。</summary>
  252. [Fact]
  253. public void T18_AlertSummary_EndpointExists()
  254. {
  255. Assert.Contains("[HttpGet(\"s8-alert-summary\")]", KanbanCode());
  256. }
  257. /// <summary>⑲ 汇总端点必须 COUNT,且不得带 LIMIT。</summary>
  258. [Fact]
  259. public void T19_AlertSummary_CountsWithoutLimit()
  260. {
  261. var body = MethodBody(KanbanCode(), "public async Task<IActionResult> GetS8AlertSummary");
  262. Assert.Contains("COUNT(*)", body);
  263. Assert.DoesNotContain("LIMIT", body);
  264. }
  265. /// <summary>⑳ 「今日」必须是显式的服务端自然日窗口,不是「最近 N 条」的副作用。</summary>
  266. [Fact]
  267. public void T20_AlertSummary_HasExplicitTodayWindow()
  268. {
  269. var body = MethodBody(KanbanCode(), "public async Task<IActionResult> GetS8AlertSummary");
  270. Assert.Contains("DateTime.Today", body);
  271. Assert.Contains("windowStart", body);
  272. Assert.Contains("windowEnd", body);
  273. Assert.Contains("isToday", body);
  274. }
  275. /// <summary>㉑ 汇总端点必须租户 + 工厂作用域,且窗口是半开区间。</summary>
  276. [Fact]
  277. public void T21_AlertSummary_IsTenantScoped()
  278. {
  279. var body = MethodBody(KanbanCode(), "public async Task<IActionResult> GetS8AlertSummary");
  280. Assert.Contains("AidopTenantHelper.GetTenantId(HttpContext)", body);
  281. Assert.Contains("tenant_id=@tenantId", body);
  282. Assert.Contains("factory_id=@factoryId", body);
  283. Assert.Contains("alert_time >= @ws", body);
  284. Assert.Contains("alert_time < @we", body);
  285. }
  286. /// <summary>㉒ 汇总端点必须复用同一套维度过滤,不得另起一套口径。</summary>
  287. [Fact]
  288. public void T22_AlertSummary_ReusesSharedDimensionFilter()
  289. {
  290. var body = MethodBody(KanbanCode(), "public async Task<IActionResult> GetS8AlertSummary");
  291. Assert.Contains("BuildS8AlertTextFilterSql()", body);
  292. Assert.Contains("SmartOpsDashboardFilter.FromQuery", body);
  293. }
  294. /// <summary>㉓ 既有 s8-alerts 列表端点不得被本批改动(home.vue 仍依赖它返回最近 6 条)。</summary>
  295. [Fact]
  296. public void T23_LegacyAlertsEndpoint_StillReturnsRecentList()
  297. {
  298. var body = MethodBody(KanbanCode(), "public async Task<IActionResult> GetS8Alerts");
  299. Assert.Contains("LIMIT 6", body);
  300. Assert.Contains("ORDER BY alert_time DESC", body);
  301. }
  302. /// <summary>㉔ 汇总端点必须同时给出严重级计数,避免前端再拿 6 条列表推严重数。</summary>
  303. [Fact]
  304. public void T24_AlertSummary_ReturnsCriticalCount()
  305. {
  306. var body = MethodBody(KanbanCode(), "public async Task<IActionResult> GetS8AlertSummary");
  307. Assert.Contains("critical", body);
  308. Assert.Contains("level_code", body);
  309. }
  310. }