| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369 |
- 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);
- }
- }
|