using System.Reflection;
using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers;
using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Health;
using Xunit;
namespace Admin.NET.Plugin.AiDOP.Tests.S8;
///
/// Rule01「当前快照」的发布契约。
///
/// 这一条守的是一个只在「合法的空」上才现形的缺陷。
/// Provider 原来按「最新的那一天」挑当前快照。源侧被清空的那一轮产出 0 行 DWD,
/// 于是当天根本不存在 stat_date 分区,中台的同日淘汰也无行可删(它只能删「今天」),
/// 「最新的那一天」就回落到上一天,把一批早已作废的行当成当前态返回。
///
/// 本地沙箱实测:租户 838257212780613 源侧 10 → 0、STD 10 → 0、S3 跑批 SUCCESS,
/// 而 DWD 当前快照仍返回前一日的 10 行。行数、状态、批次号全都「看起来正常」,
/// 没有任何一处报错 —— 这类缺陷只能靠契约守住。
///
public class S8Rule01PublicationContractTests
{
private static string Source(params string[] parts)
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP")))
dir = dir.Parent;
Assert.NotNull(dir);
var full = Path.Combine(new[] { dir!.FullName, "Admin.NET.Plugin.AiDOP" }.Concat(parts).ToArray());
Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
return File.ReadAllText(full);
}
private static string S3Service() => Source("Supply", "S3MdpSyncTransformService.cs");
private static string Resolver() =>
Source("Service", "S8", "Rules", "Health", "S8MdpAuthorityHealthResolver.cs");
private static S8AuthoritySpec Rule01Spec()
{
var spec = new S8BusinessDatasetDefinitions()
.GetDefinitions()
.Single(d => d.DatasetCode == S8BusinessDatasetDefinitions.PurchaseDeliveryCode)
.AuthoritySpec;
Assert.NotNull(spec);
return spec!;
}
// ══════════════════ 生产者侧:发布证据 ══════════════════
///
/// 发布证据只在全量路径产生。
///
/// 增量路径(RunInboundAsync)同样以 SUCCESS 收尾,但它只处理变更行。
/// 把这样一个批次指认成「当前快照」,读者拿到的就是一个残缺集合 ——
/// 而且它会以「有发布证据、状态成功」的样子出现,比没有证据更危险。
///
[Fact]
public void Publication_IsProducedOnlyOnFullPath()
{
var code = S3Service();
var publishAt = code.IndexOf("result.Publication = await PublishCurrentSnapshotAsync", StringComparison.Ordinal);
Assert.True(publishAt > 0, "全量路径必须写发布证据");
// 发布语句必须落在 if (retireStale) 之内 —— 取其之前最近的一段代码验证。
var guardAt = code.LastIndexOf("if (retireStale)", publishAt, StringComparison.Ordinal);
Assert.True(guardAt > 0 && publishAt - guardAt < 400,
"发布必须被 retireStale 守卫包住,否则增量批次会被指认成当前快照");
}
///
/// 发布证据必须在 DWD 的全部写入命令执行完之后才产生。
///
/// 顺序颠倒的后果不是报错,是读者拿到一个还没灌完(或还混着上一批残留)的快照,
/// 而运行日志会把它记成一次正常发布。
///
/// 比对的是执行顺序,不是源码顺序:同日淘汰是
/// BuildDwdCommands 这个迭代器 yield 出来的最后一条命令,
/// 而该方法在文件里排在 BuildDwdAsync 之后 —— 按源码位置比会得出相反的结论。
/// 真正的保证是:发布语句排在命令循环之后。淘汰在命令序列内部的位置,
/// 由 S3PurchaseAuthorityContractTests.DwdBuild_RetiresSameDayStaleRows_AfterInsert 守。
///
[Fact]
public void Publication_HappensAfterAllDwdCommandsExecuted()
{
var code = S3Service();
var bodyAt = code.IndexOf("private async Task BuildDwdAsync(", StringComparison.Ordinal);
Assert.True(bodyAt > 0, "BuildDwdAsync 不存在");
var loopAt = code.IndexOf("foreach (var command in BuildDwdCommands(", bodyAt, StringComparison.Ordinal);
var shortageAt = code.IndexOf("InsertMaterialShortageAsync(batchId, now)", bodyAt, StringComparison.Ordinal);
var publishAt = code.IndexOf("result.Publication = await PublishCurrentSnapshotAsync", bodyAt, StringComparison.Ordinal);
Assert.True(loopAt > bodyAt, "未找到 DWD 命令执行循环");
Assert.True(publishAt > loopAt, "发布必须在 DWD 命令循环之后");
Assert.True(publishAt > shortageAt, "发布必须在 DWD 阶段的全部写入之后");
}
///
/// 发布行数必须用复核查询得到,不能用 INSERT 的影响行数。
/// ODKU 的影响行数(插入 1 / 更新 2 / 无变化 0)从来不等于「当前有几行」。
///
[Fact]
public void Publication_CountsRowsByVerificationQuery()
{
var code = S3Service();
Assert.Contains("SELECT COUNT(*) FROM dwd_supplier_delivery", code);
Assert.Contains("AND sync_batch_id=@BatchId", code);
Assert.Contains("AND stat_date=@StatDate", code);
}
///
/// 运行摘要必须带上定位当前快照所需的全部字段,且用序列化器生成而非字符串拼接。
/// currentRows = 0 是合法值 —— 它正是「成功发布了一个空快照」的表达,
/// 下游据此把「正常的空」与「没发布成」区分开。
///
[Fact]
public void RunSummaryJson_CarriesPublicationEvidence()
{
var code = S3Service();
Assert.Contains("JsonSerializer.Serialize", code);
foreach (var field in new[] { "table =", "batchId =", "statDate =", "factoryId =", "currentRows =" })
Assert.Contains(field, code);
// 没发布时必须是 null,不能退化成一个 currentRows=0 的假证据 ——
// 那会把「没发布」伪装成「发布了空快照」,正好抹掉两者的区别。
Assert.Contains("publish = result.Publication is null", code);
}
// ══════════════════ 读者侧:Provider ══════════════════
///
/// Provider 必须按发布证据取数,且结构上不存在回落到其它 stat_date 的路径。
///
[Fact]
public void Provider_ReadsPublishedSnapshot_WithNoFallbackPath()
{
var sql = S8PurchaseDeliveryDataProvider.Sql;
Assert.Contains("WITH pub AS", sql);
Assert.Contains("'$.publish.batchId'", sql);
Assert.Contains("'$.publish.statDate'", sql);
Assert.Contains("d.sync_batch_id = p.batch_id", sql);
// 只认终态成功的生产运行:该表有大量永不回收的 RUNNING 孤儿行。
Assert.Contains("r.status = 'SUCCESS'", sql);
Assert.Contains("r.end_time IS NOT NULL", sql);
// 旧的「取最新那一天」写法必须彻底消失 —— 留着它就等于留着回落路径。
Assert.DoesNotContain("ORDER BY x.stat_date DESC", sql);
Assert.DoesNotContain("MAX(stat_date)", sql);
Assert.DoesNotContain("MAX(d.stat_date)", sql);
}
///
/// 发布证据的租户过滤必须是严格相等。
/// mdp_transform_run_log 确实存在 tenant_id = 0 的平台行;
/// 写成 OR tenant_id = 0 会让一条平台行同时充当所有租户的发布证据。
///
[Fact]
public void Provider_PublicationLookup_IsStrictlyTenantScoped()
{
var sql = S8PurchaseDeliveryDataProvider.Sql;
Assert.Contains("r.tenant_id = @TenantId", sql);
Assert.DoesNotContain("r.tenant_id = 0", sql);
Assert.DoesNotContain("OR tenant_id = 0", sql);
}
///
/// 生产者作业码只有一个来源:目录与取数各写一份必然漂移,
/// 而漂移的表现是「健康判定看着 A 作业、取数却认 B 作业的发布」,两边各自都不报错。
///
[Fact]
public void ProducerJobCode_HasSingleSource()
{
Assert.Equal(
S8BusinessDatasetDefinitions.PurchaseDeliveryProducerJobCode,
Rule01Spec().ProducerJobCode);
var provider = Source("Service", "S8", "Rules", "DataAccess", "Providers",
"S8PurchaseDeliveryDataProvider.cs");
Assert.Contains("S8BusinessDatasetDefinitions.PurchaseDeliveryProducerJobCode", provider);
Assert.DoesNotContain("\"S3_MDP_SYNC_TRANSFORM\"", provider);
}
// ══════════════════ 健康判定侧 ══════════════════
/// Rule01 的当前态必须由发布证据指认,不得退回按时间兜底。
[Fact]
public void Rule01Spec_RequiresPublicationEvidence()
{
var spec = Rule01Spec();
Assert.True(spec.RequiresPublicationEvidence);
Assert.True(spec.ProducerTrusted);
Assert.Equal(S8AuthorityKind.CurrentStateMirror, spec.AuthorityKind);
}
///
/// 需要发布证据时,「最近一次成功」只认带证据的那一次。
/// 否则一次增量成功就会把锚点从上一轮完整发布挪走,新鲜度与失败锚点全部算错。
///
[Fact]
public void Resolver_LastSuccess_FiltersOnPublicationWhenRequired()
{
var code = Resolver();
Assert.Contains("AND JSON_EXTRACT(r.summary_json, '$.publish.batchId') IS NOT NULL", code);
Assert.Contains("spec.RequiresPublicationEvidence", code);
Assert.Contains("S8AuthorityHealthReason.NoPublication", code);
}
///
/// 「没有发布证据」与「压根没成功过」是两件事,必须有各自的原因码。
/// 结论同为 UNKNOWN、同样拦恢复;分开只是为了排查时一眼看出是哪一种。
///
[Fact]
public void NoPublicationReason_IsDistinct_AndBlocksRecovery()
{
Assert.NotEqual(S8AuthorityHealthReason.NoSuccessfulRun, S8AuthorityHealthReason.NoPublication);
Assert.NotEqual(S8AuthorityHealthReason.Healthy, S8AuthorityHealthReason.NoPublication);
var result = new S8AuthorityHealthResult
{
State = S8AuthorityHealthState.Unknown,
ReasonCode = S8AuthorityHealthReason.NoPublication
};
Assert.False(result.AllowsRecovery);
}
///
/// 空快照不是一种新状态。
/// 成功发布的空快照就是 HEALTHY,恢复照常放行;缺的是证据时才判 UNKNOWN。
/// 词表里绝不能冒出 HEALTHY_EMPTY 这种第四态 —— 那会让每个读方各自决定它算不算健康。
///
[Fact]
public void StateVocabulary_HasNoHealthyEmpty()
{
var states = typeof(S8AuthorityHealthState)
.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy)
.Where(f => f.IsLiteral && !f.IsInitOnly && f.FieldType == typeof(string))
.Select(f => (string)f.GetRawConstantValue()!)
.ToArray();
Assert.Equal(3, states.Length);
Assert.DoesNotContain("HEALTHY_EMPTY", states);
}
}