using System.Reflection;
using Microsoft.AspNetCore.Mvc;
using Admin.NET.Plugin.AiDOP.Controllers.S8;
using Admin.NET.Plugin.AiDOP.Service.S8;
using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
using Xunit;
namespace Admin.NET.Plugin.AiDOP.Tests.S8;
///
/// S8-RULE01-INTEGRATION-PREVIEW-1:预演端点的**零副作用**契约。
///
/// 预演的全部价值建立在一条保证上:它什么都不写。
/// 一旦有人给它加一个"顺便把命中落库"的分支,它就从"启用前的安全窗口"
/// 变成"另一条没人审过的建单入口" —— 而且因为名字叫 preview,没人会去审它。
/// 因此本文件用结构性断言把这条钉死,而不是依赖注释和自觉。
///
/// 不接 DB、不接 DI:全部断言基于类型结构与依赖形态。
///
public class S8WatchRulePreviewContractTests
{
private static readonly Type Service = typeof(S8WatchRulePreviewService);
///
/// 预演服务只允许持有「取数 + 判定 + 读既有异常」所需的依赖。
/// 出现任何建单 / 通知 / 流转类服务都意味着它可能产生副作用。
///
[Fact]
public void PreviewService_HoldsNoWriteCapableDependency()
{
var deps = Assert.Single(Service.GetConstructors())
.GetParameters().Select(p => p.ParameterType.Name).ToArray();
foreach (var forbidden in new[]
{
"S8ManualReportService", // 建单
"S8TaskFlowService", // 状态流转
"S8NotificationLayerResolver",// 通知
"S8WatchSchedulerService", // 会写 detection / 推进租约
"S8ExceptionService" // 异常写入口
})
Assert.DoesNotContain(forbidden, deps);
// 正向:取数网关与两个只读仓储必须在,否则它没法走生产同款链路。
Assert.Contains("S8MonitoringDataGateway", deps);
}
///
/// 预演服务不得出现任何写方法。命名约定在本仓是稳定的
/// (Create/Update/Delete/Save/Insert/Write/Execute…),逐个排除比读注释可靠。
///
[Fact]
public void PreviewService_ExposesOnlyPreview()
{
var publicMethods = Service
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.Select(m => m.Name)
.ToArray();
Assert.Equal(new[] { "PreviewAsync" }, publicMethods);
foreach (var verb in new[] { "Create", "Update", "Delete", "Save", "Insert", "Write", "Run", "Execute" })
Assert.DoesNotContain(publicMethods, n => n.StartsWith(verb, StringComparison.Ordinal));
}
///
/// 端点必须是 GET。用 POST 会让调用方以为"点一下会发生点什么",
/// 也会让它在审计与网关限流里被归入写操作那一类。
///
[Fact]
public void PreviewEndpoint_IsHttpGet_AndScoped()
{
var action = Assert.Single(
typeof(AdoS8ConfigWatchRulesController)
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.Where(m => m.Name == "PreviewAsync"));
var get = Assert.Single(action.GetCustomAttributes());
Assert.Equal("{id:long}/preview", get.Template);
Assert.Empty(action.GetCustomAttributes());
Assert.Empty(action.GetCustomAttributes());
Assert.Empty(action.GetCustomAttributes());
// 必须带权限;未登记路由会退回平台的「默认放行」。
Assert.NotEmpty(action.GetCustomAttributes()
.Where(a => a.GetType().Name.Contains("S8Permission", StringComparison.Ordinal)));
}
///
/// 预演必须能对 enabled=false 的规则工作 —— 这正是它存在的理由:
/// 先看清会命中什么,再决定启不启用。若签名或结果里带上"仅启用规则"的约束,
/// 它就退化成了事后观察工具。
///
[Fact]
public void PreviewResult_ReportsEnabledState_WithoutRequiringIt()
{
var enabled = typeof(S8WatchRulePreviewResult).GetProperty("Enabled");
Assert.NotNull(enabled);
Assert.Equal(typeof(bool), enabled!.PropertyType);
// 结果必须能回答"会新建多少 / 会命中既有多少"——只给总命中数无法评估污染规模。
foreach (var required in new[]
{ "CandidateRows", "HitCount", "NoHitRows", "WouldCreateCount", "WouldMatchExistingCount" })
Assert.NotNull(typeof(S8WatchRulePreviewResult).GetProperty(required));
}
///
/// 单条命中必须带 DedupKey 与既有异常 id:
/// 没有这两项就无法回答"真跑一次会不会重复建单",而那正是启用前最该确认的事。
///
[Fact]
public void PreviewHit_CarriesIdentityAndDedupContext()
{
foreach (var required in new[]
{
"SourceObjectType", "SourceObjectId", "RelatedObjectCode",
"ExceptionTypeCode", "Severity", "DedupKey", "ExistingActiveExceptionId"
})
Assert.NotNull(typeof(S8WatchRulePreviewHit).GetProperty(required));
Assert.Equal(typeof(long?), typeof(S8WatchRulePreviewHit).GetProperty("ExistingActiveExceptionId")!.PropertyType);
}
// ============================================================
// S8-RULE-LIFECYCLE-CREATE-GATE-1:预演与首次立案资格的口径对齐
//
// 修的是一个已在本地 Runtime 实测到的分叉:
// 预演 命中 3 · 将新建 3
// 真跑 hits=3 · created=2 (1 条 create_not_eligible)
// 差的那一条是已过客户交期、风险已经兑现的历史行 —— 它是真实命中
// (因而保护既有异常不被误判恢复),但不该再开一个新案子。
// 管理员正是拿「将新建」这个数字决定要不要启用,报大了比不报更糟。
// ============================================================
private static S8RuleHit Hit(string dedupKey, bool createEligible) => new()
{
SourceRuleCode = "RULE_UNDER_TEST",
SourceObjectType = "SALES_ORDER_LINE",
SourceObjectId = dedupKey,
RelatedObjectCode = dedupKey,
DedupKey = dedupKey,
CreateEligible = createEligible
};
private static IReadOnlyDictionary Active(params string[] dedupKeys) =>
dedupKeys.ToDictionary(k => k, k => (long)k.GetHashCode(), StringComparer.Ordinal);
private static readonly IReadOnlyDictionary NoneActive =
new Dictionary(StringComparer.Ordinal);
/// P1:CreateEligible=false 的命中仍然是命中。
[Fact]
public void P1_IneligibleHit_StillCountsAsHit()
{
var hits = new[] { Hit("A", true), Hit("B", true), Hit("C", false) };
// HitCount 直接取 hits.Count(见 RunAsync)——这里锁的是"不得在命中侧做任何过滤"。
// 一旦有人为了让数字好看而把 ineligible 从 hits 里剔掉,恢复判定会立刻失去保护,
// 假恢复原地复活。
Assert.Equal(3, hits.Length);
Assert.Contains(hits, h => !h.CreateEligible);
}
/// P2:CreateEligible=false 不计入 WouldCreate。
[Fact]
public void P2_IneligibleHit_IsNotCountedAsWouldCreate()
{
var hits = new[] { Hit("A", true), Hit("B", true), Hit("C", false) };
Assert.Equal(2, S8WatchRulePreviewService.CountWouldCreate(hits, NoneActive));
}
/// P3:CreateEligible=true 仍按原有去重口径计入。
[Fact]
public void P3_EligibleHits_KeepExistingDedupSemantics()
{
var hits = new[] { Hit("A", true), Hit("B", true) };
Assert.Equal(2, S8WatchRulePreviewService.CountWouldCreate(hits, NoneActive));
// A 已有活动异常 → 它会被刷新而不是新建(既有语义,未改动)。
Assert.Equal(1, S8WatchRulePreviewService.CountWouldCreate(hits, Active("A")));
Assert.Equal(1, S8WatchRulePreviewService.CountWouldMatchExisting(hits, Active("A")));
}
///
/// P4:未声明闸门的规则(Rule 01 即是)行为逐字不变。
/// CreateEligible 默认 true,因此新过滤对它恒真、不改变任何计数。
///
[Fact]
public void P4_RulesWithoutTheGate_AreUnaffected()
{
Assert.True(new S8RuleHit().CreateEligible);
var rule01Style = new[] { new S8RuleHit { DedupKey = "P1" }, new S8RuleHit { DedupKey = "P2" } };
Assert.Equal(2, S8WatchRulePreviewService.CountWouldCreate(rule01Style, NoneActive));
Assert.Equal(1, S8WatchRulePreviewService.CountWouldCreate(rule01Style, Active("P1")));
Assert.Equal(1, S8WatchRulePreviewService.CountWouldMatchExisting(rule01Style, Active("P1")));
}
///
/// P5:预演仍然零写入。本批只改了两个计数表达式,不得因此引入任何写能力。
/// (与本文件开头的依赖/方法名断言互补:那两条从依赖面证明,这条从源码面证明。)
///
[Fact]
public void P5_PreviewRemainsWriteFree()
{
var code = PreviewSource();
foreach (var write in new[]
{ "Insertable", "Updateable", "Deleteable", "ExecuteCommand", "SaveChanges" })
Assert.DoesNotContain(write, code, StringComparison.Ordinal);
}
/// P6:不得出现 RuleCode 特判 —— 闸门是通用能力,不是 Rule 02 的特例。
[Fact]
public void P6_NoRuleCodeSpecialCasing()
{
var code = PreviewSource();
Assert.DoesNotContain("RULE_S1_ORDER_DELIVERY_DELAY_WARNING", code, StringComparison.Ordinal);
Assert.DoesNotContain("RULE_S4_PURCHASE_DELIVERY", code, StringComparison.Ordinal);
Assert.DoesNotContain("CreateOnlyBeforeDueAt", code, StringComparison.Ordinal);
}
///
/// P7:Rule 02 本地实测形态 —— 2 条未到期 + 1 条已过交期,均无既有异常。
/// 期望 Hits=3 / WouldCreate=2,与真跑的 hits=3 created=2 逐字一致。
///
[Fact]
public void P7_Rule02LocalShape_HitsThree_WouldCreateTwo()
{
var hits = new[]
{
Hit("T838257186181189:R…:SALES_ORDER_LINE:SO202609030003#1", true), // 未到期
Hit("T838257186181189:R…:SALES_ORDER_LINE:SO202609030004#1", true), // 未到期
Hit("T838257186181189:R…:SALES_ORDER_LINE:SO202608240001#1", false) // 已过交期
};
Assert.Equal(3, hits.Length);
Assert.Equal(2, S8WatchRulePreviewService.CountWouldCreate(hits, NoneActive));
Assert.Equal(0, S8WatchRulePreviewService.CountWouldMatchExisting(hits, NoneActive));
}
///
/// P8:跨过交期的既有预警必须仍报「会刷新」。
/// 刷新分支不受闸门影响 —— 闸门在调度器里位于刷新之后。
/// 若这里也过滤,预演会把「保持 active」误报成「什么都不会发生」。
///
[Fact]
public void P8_IneligibleHitWithExistingException_StillReportsRefresh()
{
var hits = new[] { Hit("C", false) };
Assert.Equal(0, S8WatchRulePreviewService.CountWouldCreate(hits, Active("C")));
Assert.Equal(1, S8WatchRulePreviewService.CountWouldMatchExisting(hits, Active("C")));
}
private static string PreviewSource()
{
var path = Path.GetFullPath(Path.Combine(
AppContext.BaseDirectory,
"../../../../Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRulePreviewService.cs"));
Assert.True(File.Exists(path), $"预演服务源码路径需同步更新:{path}");
return string.Join('\n', File.ReadAllLines(path)
.Where(l =>
{
var t = l.TrimStart();
return !t.StartsWith("///", StringComparison.Ordinal)
&& !t.StartsWith("//", StringComparison.Ordinal);
}));
}
}