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; /// /// S8-DASHBOARD-P2-SEMANTICS-1:看板语义守卫(P2-B 今日 / P2-C 趋势桶 / P2-D 分母)。 /// /// P2-C 的缺陷是一个纯函数缺陷:last_24h 的窗口是 /// [Now-24h, Now)(非零点),而日桶是按 CreatedAt.Date(零点)分组的, /// 原实现用 from.AddDays(i) 直接拿非零点的 from 当桶键去 TryGetValue, /// 永远 miss ⇒ 峰值 / 均值 / 今日 恒为 0。因此桶枚举被抽成 /// 后可以确定性验证,不依赖实库时钟。 /// /// 服务层方法需要 SqlSugar 实库、进程内无法构造,故其余判据沿用本仓既有范式 /// (反射元数据 + 去注释源码契约),与 S8OrderArchiveAuthorityTests 一致。 /// 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"); /// /// 取某个方法体:从签名起,到下一个「类成员级(4 空格缩进)声明」之前。 /// 必须把 private / internal 也算进终止符,否则方法体会吃进后面的私有辅助方法, /// 让 DoesNotContain 类断言变成假失败。 /// 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 · 日桶枚举(纯函数,确定性) ══════════════ /// ① today:窗口本就零点对齐,1 个桶,行为与历史一致。 [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); } /// ② last_24h:跨两个自然日,必须给出 2 个桶(原实现只给 1 个)。 [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]); } /// ③ 桶键必须零点对齐——否则与 GroupBy(r => r.CreatedAt.Date) 永远对不上。 [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)); } /// ④ 桶必须覆盖整个查询窗口:Σ桶 == 窗口内行数。这是本次修复的核心不变量。 [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); } /// ⑤ 原实现的反例固定下来:用非零点 from 当桶键,一行都命中不了。 [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)); } /// ⑥ last_7d / this_week 等零点对齐窗口行为不变(回归保护)。 [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); } /// ⑦ 超上限(如 92 天的季度)时保留最近 N 天,且查询窗口同步收紧,Σ桶仍等于行数。 [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); } /// ⑧ 查询窗口只能收紧、不得放宽(防止桶对齐把窗口撑大而多统计行)。 [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); } /// ⑨ 边界不完整时必须自报(供上层抑制「今日 vs 昨日」环比)。 [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); } /// ⑩ 空窗 / 倒挂窗不得抛异常,至少给 1 个桶。 [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 · 三条趋势必须走同一枚举 ══════════════ /// ⑪ 三条趋势方法都必须改用共享桶枚举。 [Theory] [InlineData("GetDeliveryTrendAsync")] [InlineData("GetProductionTrendAsync")] [InlineData("GetSupplyTrendAsync")] public void T11_TrendMethods_UseSharedBucketPlan(string method) { var body = MethodBody(MonitoringCode(), $"public async Task {method}"); Assert.Contains("ResolveTrendWindow", body); Assert.Contains("plan.Buckets", body); } /// ⑫ 不得再出现「用 from 自增当桶键」的旧写法。 [Theory] [InlineData("GetDeliveryTrendAsync")] [InlineData("GetProductionTrendAsync")] [InlineData("GetSupplyTrendAsync")] public void T12_TrendMethods_DropRawFromAddDaysBucketKey(string method) { var body = MethodBody(MonitoringCode(), $"public async Task {method}"); Assert.DoesNotContain("var d = from.AddDays(i)", body); Assert.DoesNotContain("Math.Min(90, (int)(toExclusive - from).TotalDays)", body); } /// ⑬ 三条趋势的摘要必须走同一个构建器(口径不得各写各的)。 [Theory] [InlineData("GetDeliveryTrendAsync")] [InlineData("GetProductionTrendAsync")] [InlineData("GetSupplyTrendAsync")] public void T13_TrendMethods_ShareSummaryBuilder(string method) { var body = MethodBody(MonitoringCode(), $"public async Task {method}"); Assert.Contains("BuildTrendSummary(plan,", body); // 不得再各自手搓环比 Assert.DoesNotContain("changeRate", body); } /// ⑬b 边界桶不完整时,日环比必须置 null(不得拿半天比全天)。 [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); } /// ⑭ 趋势出参必须自带统计窗口,口径可被前端与审计核对。 [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 · 每百订单分母 ══════════════ /// ⑮ 无分母 Authority ⇒ Frequency 必须可空,用 null 表达 NO_DATA。 [Fact] public void T15_Frequency_IsNullable_ForNoData() { var p = typeof(AdoS8ModuleOrderSummary).GetProperty("Frequency"); Assert.NotNull(p); Assert.Equal(typeof(double?), p!.PropertyType); } /// ⑯ 绝不能再把「异常绝对数」当成「每百订单异常数」发出去。 [Fact] public void T16_Frequency_IsNotAssignedRawTotal() { var code = MonitoringCode(); Assert.DoesNotContain("Frequency = total,", code); Assert.DoesNotContain("Frequency = total,", code); Assert.Contains("Frequency = null,", code); } /// ⑰ 不得为了让卡片活着而自造分母(拿销售订单数去除以采购异常数等)。 [Fact] public void T17_Frequency_DoesNotInventDenominator() { var body = MethodBody(MonitoringCode(), "public async Task GetOrderGridAsync"); Assert.DoesNotContain("mdp_std_so", body); Assert.DoesNotContain("* 100.0 / orderCount", body); } // ══════════════ P2-B · 今日预警数 ══════════════ /// ⑱ 必须存在一个真正做计数的汇总端点(列表端点的 LIMIT 不能当计数)。 [Fact] public void T18_AlertSummary_EndpointExists() { Assert.Contains("[HttpGet(\"s8-alert-summary\")]", KanbanCode()); } /// ⑲ 汇总端点必须 COUNT,且不得带 LIMIT。 [Fact] public void T19_AlertSummary_CountsWithoutLimit() { var body = MethodBody(KanbanCode(), "public async Task GetS8AlertSummary"); Assert.Contains("COUNT(*)", body); Assert.DoesNotContain("LIMIT", body); } /// ⑳ 「今日」必须是显式的服务端自然日窗口,不是「最近 N 条」的副作用。 [Fact] public void T20_AlertSummary_HasExplicitTodayWindow() { var body = MethodBody(KanbanCode(), "public async Task GetS8AlertSummary"); Assert.Contains("DateTime.Today", body); Assert.Contains("windowStart", body); Assert.Contains("windowEnd", body); Assert.Contains("isToday", body); } /// ㉑ 汇总端点必须租户 + 工厂作用域,且窗口是半开区间。 [Fact] public void T21_AlertSummary_IsTenantScoped() { var body = MethodBody(KanbanCode(), "public async Task 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); } /// ㉒ 汇总端点必须复用同一套维度过滤,不得另起一套口径。 [Fact] public void T22_AlertSummary_ReusesSharedDimensionFilter() { var body = MethodBody(KanbanCode(), "public async Task GetS8AlertSummary"); Assert.Contains("BuildS8AlertTextFilterSql()", body); Assert.Contains("SmartOpsDashboardFilter.FromQuery", body); } /// ㉓ 既有 s8-alerts 列表端点不得被本批改动(home.vue 仍依赖它返回最近 6 条)。 [Fact] public void T23_LegacyAlertsEndpoint_StillReturnsRecentList() { var body = MethodBody(KanbanCode(), "public async Task GetS8Alerts"); Assert.Contains("LIMIT 6", body); Assert.Contains("ORDER BY alert_time DESC", body); } /// ㉔ 汇总端点必须同时给出严重级计数,避免前端再拿 6 条列表推严重数。 [Fact] public void T24_AlertSummary_ReturnsCriticalCount() { var body = MethodBody(KanbanCode(), "public async Task GetS8AlertSummary"); Assert.Contains("critical", body); Assert.Contains("level_code", body); } }