using System.Reflection; using System.Text.RegularExpressions; using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers; using Xunit; namespace Admin.NET.Test.S8; /// /// S8-RULE01-SQL-MAPPING-GUARD-1:SQL 结果别名与 POCO 属性的映射契约。 /// /// 为什么需要单独一个文件:本仓 DbSettings.EnableUnderLine = false, /// SqlSugar 不做 snake_case → PascalCase 换算,Ado.SqlQueryAsync<T> 按名字直接匹配。 /// 别名一旦写成 snake_case,映射会**静默失败**: /// · 不抛异常 /// · 行数照常返回 /// · 但每个属性都是 null / default /// 于是 evaluator 在 due == null 处逐行跳过,命中恒为 0 —— 表面像"当前没有超期单", /// 实际是取数根本没落到对象上。本地全链实验中真实发生过一次(36 候选 / 0 命中)。 /// /// 为什么既有测试抓不到:Provider 映射契约测试是直接 new PurchaseDeliveryRow{...} /// 再喂 evaluator,整条 SqlSugar 映射被绕开;E2E 测试用的 stub Provider 同理。 /// 两者都在"映射之后"开始验证,因此本文件必须从 SQL 文本本身入手。 /// /// 不接 DB、不接 DI:解析 SQL 常量 + 反射比对属性名。 /// public class S8PurchaseDeliverySqlMappingTests { /// 取 SELECT 列表里所有 AS <alias> 的别名。 private static string[] SelectAliases() => Regex.Matches(S8PurchaseDeliveryDataProvider.Sql, @"\bAS\s+([A-Za-z_][A-Za-z0-9_]*)", RegexOptions.IgnoreCase) .Select(m => m.Groups[1].Value) .ToArray(); private static string[] RowProperties() => typeof(S8PurchaseDeliveryDataProvider.PurchaseDeliveryRow) .GetProperties(BindingFlags.Public | BindingFlags.Instance) .Select(p => p.Name) .ToArray(); /// /// 每个 SQL 别名都必须**逐字**等于某个属性名。 /// 用 Ordinal 而非忽略大小写:SqlSugar 的匹配行为不该靠"大概能对上"来赌。 /// [Fact] public void EverySelectAlias_MapsToRowProperty() { var props = RowProperties(); var aliases = SelectAliases(); Assert.NotEmpty(aliases); var unmapped = aliases.Where(a => !props.Contains(a, StringComparer.Ordinal)).ToArray(); Assert.True(unmapped.Length == 0, "以下 SQL 别名在 PurchaseDeliveryRow 上找不到同名属性,SqlSugar 会静默映射为 null:\n " + string.Join("\n ", unmapped)); } /// /// 反向:每个属性都必须被某个别名覆盖。 /// 漏掉一个属性同样是静默 null —— 例如漏掉 DueAt 就直接导致命中恒为 0。 /// [Fact] public void EveryRowProperty_IsProducedBySql() { var aliases = SelectAliases(); var missing = RowProperties().Where(p => !aliases.Contains(p, StringComparer.Ordinal)).ToArray(); Assert.True(missing.Length == 0, "以下属性没有对应的 SQL 别名,取数后将恒为 null:\n " + string.Join("\n ", missing)); } /// /// 别名不得含下划线。EnableUnderLine=false 时 snake_case 别名等于"注定映射不上", /// 而失败形态是静默的,所以这条要单独钉死,而不是依赖上面两条的间接覆盖。 /// [Fact] public void NoAlias_UsesSnakeCase() { var snake = SelectAliases().Where(a => a.Contains('_', StringComparison.Ordinal)).ToArray(); Assert.True(snake.Length == 0, "本仓 EnableUnderLine=false,snake_case 别名会静默映射失败:\n " + string.Join("\n ", snake)); } /// /// 驱动判定的四个 canonical 字段必须齐备。 /// 其余诊断列缺失只是 payload 少信息,这四个缺一个就直接改变判定结果。 /// [Theory] [InlineData("SourceObjectId")] [InlineData("RelatedObjectCode")] [InlineData("DueAt")] [InlineData("Status")] public void CanonicalDrivingField_IsSelected(string alias) { Assert.Contains(alias, SelectAliases(), StringComparer.Ordinal); } /// /// WHERE / FROM 仍必须用数据库真实列名(snake_case)—— /// 别名只改输出侧,改错这一侧会直接让 SQL 报「Unknown column」。 /// [Fact] public void PhysicalColumnNames_StayUnchangedInPredicates() { var sql = S8PurchaseDeliveryDataProvider.Sql; Assert.Contains("d.tenant_id = @TenantId", sql, StringComparison.Ordinal); Assert.Contains("d.stat_date =", sql, StringComparison.Ordinal); Assert.Contains("d.po_no <> ''", sql, StringComparison.Ordinal); Assert.Contains("d.po_line <> ''", sql, StringComparison.Ordinal); Assert.Contains("FROM dwd_supplier_delivery d", sql, StringComparison.Ordinal); } }