|
@@ -2,6 +2,7 @@ using System.Reflection;
|
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
|
using Admin.NET.Plugin.AiDOP.Controllers.S8;
|
|
using Admin.NET.Plugin.AiDOP.Controllers.S8;
|
|
|
using Admin.NET.Plugin.AiDOP.Service.S8;
|
|
using Admin.NET.Plugin.AiDOP.Service.S8;
|
|
|
|
|
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
|
|
|
using Xunit;
|
|
using Xunit;
|
|
|
|
|
|
|
|
namespace Admin.NET.Plugin.AiDOP.Tests.S8;
|
|
namespace Admin.NET.Plugin.AiDOP.Tests.S8;
|
|
@@ -120,4 +121,155 @@ public class S8WatchRulePreviewContractTests
|
|
|
|
|
|
|
|
Assert.Equal(typeof(long?), typeof(S8WatchRulePreviewHit).GetProperty("ExistingActiveExceptionId")!.PropertyType);
|
|
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<string, long> Active(params string[] dedupKeys) =>
|
|
|
|
|
+ dedupKeys.ToDictionary(k => k, k => (long)k.GetHashCode(), StringComparer.Ordinal);
|
|
|
|
|
+
|
|
|
|
|
+ private static readonly IReadOnlyDictionary<string, long> NoneActive =
|
|
|
|
|
+ new Dictionary<string, long>(StringComparer.Ordinal);
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>P1:CreateEligible=false 的命中<b>仍然是命中</b>。</summary>
|
|
|
|
|
+ [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);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>P2:CreateEligible=false 不计入 WouldCreate。</summary>
|
|
|
|
|
+ [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));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>P3:CreateEligible=true 仍按原有去重口径计入。</summary>
|
|
|
|
|
+ [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")));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// P4:未声明闸门的规则(Rule 01 即是)行为逐字不变。
|
|
|
|
|
+ /// <c>CreateEligible</c> 默认 true,因此新过滤对它恒真、不改变任何计数。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [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")));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// P5:预演仍然零写入。本批只改了两个计数表达式,不得因此引入任何写能力。
|
|
|
|
|
+ /// (与本文件开头的依赖/方法名断言互补:那两条从依赖面证明,这条从源码面证明。)
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void P5_PreviewRemainsWriteFree()
|
|
|
|
|
+ {
|
|
|
|
|
+ var code = PreviewSource();
|
|
|
|
|
+
|
|
|
|
|
+ foreach (var write in new[]
|
|
|
|
|
+ { "Insertable", "Updateable", "Deleteable", "ExecuteCommand", "SaveChanges" })
|
|
|
|
|
+ Assert.DoesNotContain(write, code, StringComparison.Ordinal);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>P6:不得出现 RuleCode 特判 —— 闸门是通用能力,不是 Rule 02 的特例。</summary>
|
|
|
|
|
+ [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);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// P7:Rule 02 本地实测形态 —— 2 条未到期 + 1 条已过交期,均无既有异常。
|
|
|
|
|
+ /// 期望 Hits=3 / WouldCreate=2,与真跑的 <c>hits=3 created=2</c> 逐字一致。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [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));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// P8:跨过交期的既有预警必须仍报「会刷新」。
|
|
|
|
|
+ /// 刷新分支<b>不</b>受闸门影响 —— 闸门在调度器里位于刷新之后。
|
|
|
|
|
+ /// 若这里也过滤,预演会把「保持 active」误报成「什么都不会发生」。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [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);
|
|
|
|
|
+ }));
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|