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