StockQueryUiAndPaginationTests.cs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. using System.Text.RegularExpressions;
  2. using Admin.NET.Plugin.AiDOP.DataPlatform;
  3. using Admin.NET.Plugin.AiDOP.Infrastructure;
  4. using Admin.NET.Plugin.AiDOP.MaterialWarehouse;
  5. using Microsoft.Extensions.Logging.Abstractions;
  6. using Microsoft.Extensions.Options;
  7. using SqlSugar;
  8. using Xunit;
  9. namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse;
  10. /// <summary>
  11. /// S5 库存查询页面收口:分页稳定性 · 库位下拉口径 · 数据来源三态语义。
  12. /// **门禁**:仅当环境变量 AIDOP_IT=1 时执行。全程只读。
  13. /// </summary>
  14. [Trait("Category", "Integration")]
  15. public class StockQueryUiAndPaginationTests
  16. {
  17. private const long UatTenant = 838257186181189L;
  18. private const long AidopTenant = 797403760988229L;
  19. private const string SourceCode = "DOPDEMORQ_SQLSERVER";
  20. private static bool Enabled => Environment.GetEnvironmentVariable("AIDOP_IT") == "1";
  21. private readonly Xunit.Abstractions.ITestOutputHelper _out;
  22. public StockQueryUiAndPaginationTests(Xunit.Abstractions.ITestOutputHelper output) => _out = output;
  23. private static ISqlSugarClient BuildAidopdev()
  24. {
  25. const string path = "/home/yy968/work/New9S/AiDOPWarehouse/server/Admin.NET.Application/Configuration/Database.json";
  26. if (!File.Exists(path)) return null;
  27. var cs = File.ReadLines(path)
  28. .Select(l => l.Trim())
  29. .Where(l => !l.StartsWith("//") && l.Contains("\"ConnectionString\"") && l.Contains("Database=aidopdev") && l.Contains("123.60.180.165"))
  30. .Select(l => Regex.Match(l, "\"ConnectionString\"\\s*:\\s*\"([^\"]+)\"").Groups[1].Value)
  31. .FirstOrDefault(v => !string.IsNullOrEmpty(v));
  32. if (string.IsNullOrEmpty(cs)) return null;
  33. return new SqlSugarScope(new ConnectionConfig
  34. {
  35. ConfigId = "aidopdev-stockquery-ui-it", DbType = DbType.MySql, ConnectionString = cs, IsAutoCloseConnection = true,
  36. });
  37. }
  38. private static AidopInventoryOptions Opt(string sourceCode = SourceCode) => new() { SourceCode = sourceCode };
  39. private static SourceDomainTenantResolver Resolver(ISqlSugarClient db) => new(db);
  40. private static StdInventorySource Std(ISqlSugarClient db) => new(db, Options.Create(Opt()));
  41. private static Live165InventorySource Live(ISqlSugarClient db, string sourceCode = SourceCode) =>
  42. new(new MdpSourceScopeFactory(db), db, Options.Create(Opt(sourceCode)));
  43. private static InventoryBalanceQuery Q(long tenantId, string domain, int page, int pageSize,
  44. string location = null, string materialCode = null) => new()
  45. {
  46. TenantId = tenantId, Domain = domain, Location = location, MaterialCode = materialCode,
  47. Page = page, PageSize = pageSize
  48. };
  49. private static string RowKey(InventoryBalanceRow r) =>
  50. $"{r.Location}|{r.MaterialCode}|{r.BatchNo}|{r.UnrestrictedStock}|{r.QcStock}|{r.FrozenStock}|{r.QtyOnHand}";
  51. /// <summary>按 pageSize 抓完整分页,返回 (total, 抓到的全部行)。</summary>
  52. private static async Task<(int Total, List<InventoryBalanceRow> Rows)> FetchAllAsync(
  53. StdInventorySource std, InventoryBalanceQuery seed, int pageSize)
  54. {
  55. var rows = new List<InventoryBalanceRow>();
  56. var total = 0;
  57. for (var page = 1; page <= 500; page++)
  58. {
  59. var p = await std.QueryPageAsync(Q(seed.TenantId, seed.Domain, page, pageSize, seed.Location, seed.MaterialCode));
  60. total = p.Total;
  61. rows.AddRange(p.List);
  62. if (page * pageSize >= p.Total) break;
  63. }
  64. return (total, rows);
  65. }
  66. // ===== 任务 2 · Case 1 / 2:抓取行数 == total,且去重后 == total =====
  67. [Fact]
  68. public async Task Case1_2_StdPagination_FetchedAndUniqueEqualTotal()
  69. {
  70. if (!Enabled) return;
  71. var db = BuildAidopdev();
  72. Assert.NotNull(db);
  73. var domain = await Resolver(db).ResolveDomainAsync(SourceCode, UatTenant);
  74. var (total, rows) = await FetchAllAsync(Std(db), Q(UatTenant, domain, 1, 50), 50);
  75. var unique = rows.Select(RowKey).Distinct().Count();
  76. _out.WriteLine($"pageSize=50 total={total} fetched={rows.Count} unique={unique}");
  77. Assert.True(total > 0, "STD 应有数据,否则本用例无意义");
  78. Assert.Equal(total, rows.Count);
  79. Assert.Equal(total, unique);
  80. }
  81. [Theory]
  82. [InlineData(10)]
  83. [InlineData(50)]
  84. [InlineData(200)]
  85. public async Task Case1_StdPagination_StableAcrossPageSizes(int pageSize)
  86. {
  87. if (!Enabled) return;
  88. var db = BuildAidopdev();
  89. Assert.NotNull(db);
  90. var domain = await Resolver(db).ResolveDomainAsync(SourceCode, UatTenant);
  91. var (total, rows) = await FetchAllAsync(Std(db), Q(UatTenant, domain, 1, pageSize), pageSize);
  92. _out.WriteLine($"pageSize={pageSize} total={total} fetched={rows.Count}");
  93. Assert.Equal(total, rows.Count);
  94. }
  95. // ===== 任务 2 · Case 3:重复 3 次结果集合一致 =====
  96. [Fact]
  97. public async Task Case3_StdPagination_RepeatableAcrossThreeRuns()
  98. {
  99. if (!Enabled) return;
  100. var db = BuildAidopdev();
  101. Assert.NotNull(db);
  102. var domain = await Resolver(db).ResolveDomainAsync(SourceCode, UatTenant);
  103. List<string> first = null;
  104. for (var run = 1; run <= 3; run++)
  105. {
  106. var (_, rows) = await FetchAllAsync(Std(db), Q(UatTenant, domain, 1, 50), 50);
  107. var keys = rows.Select(RowKey).OrderBy(x => x, StringComparer.Ordinal).ToList();
  108. if (first is null) first = keys;
  109. else Assert.Equal(first, keys);
  110. }
  111. _out.WriteLine($"3 轮抓取结果集合一致,rows={first!.Count}");
  112. }
  113. // ===== 任务 2 · Case 4:合法 Location 筛选下同样稳定 =====
  114. [Fact]
  115. public async Task Case4_StdPagination_StableUnderLocationFilter()
  116. {
  117. if (!Enabled) return;
  118. var db = BuildAidopdev();
  119. Assert.NotNull(db);
  120. var domain = await Resolver(db).ResolveDomainAsync(SourceCode, UatTenant);
  121. var allowed = await TenantLocationScopeLoader.LoadAsync(db, UatTenant, domain);
  122. var hit = 0;
  123. foreach (var loc in allowed)
  124. {
  125. var (total, rows) = await FetchAllAsync(Std(db), Q(UatTenant, domain, 1, 10, location: loc), 10);
  126. Assert.Equal(total, rows.Count);
  127. Assert.Equal(total, rows.Select(RowKey).Distinct().Count());
  128. if (total > 0) hit++;
  129. }
  130. _out.WriteLine($"按库位逐个校验分页稳定,有数据的库位数={hit}/{allowed.Count}");
  131. Assert.True(hit > 0);
  132. }
  133. // ===== 任务 3:库位下拉必须与查询层同口径(排除 Supp、去重)=====
  134. [Theory]
  135. [InlineData(UatTenant)]
  136. [InlineData(AidopTenant)]
  137. public async Task LocationOptions_MatchesQueryScope_AndExcludesSupp(long tenantId)
  138. {
  139. if (!Enabled) return;
  140. var db = BuildAidopdev();
  141. Assert.NotNull(db);
  142. var domain = await Resolver(db).ResolveDomainAsync(SourceCode, tenantId);
  143. var options = await TenantLocationScopeLoader.LoadOptionsAsync(db, tenantId, domain);
  144. var scope = await TenantLocationScopeLoader.LoadAsync(db, tenantId, domain);
  145. var supp = (await db.Ado.SqlQueryAsync<string>(
  146. "SELECT DISTINCT Location FROM LocationMaster WHERE tenant_id=@T AND Domain=@D AND Typed='Supp' AND TRIM(Location)<>''",
  147. new List<SugarParameter> { new("@T", tenantId), new("@D", domain) }))
  148. .Select(x => x.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase);
  149. var optionVals = options.Select(x => x.Val?.Trim()).Where(x => !string.IsNullOrEmpty(x)).ToList();
  150. _out.WriteLine($"tenant={tenantId} domain={domain} options={optionVals.Count} scope={scope.Count} suppInMaster={supp.Count}");
  151. // ① 下拉集合 == 查询层白名单集合
  152. Assert.Equal(
  153. scope.Select(x => x.Trim()).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList(),
  154. optionVals.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList());
  155. // ② 无 Supp
  156. Assert.All(optionVals, v => Assert.False(supp.Contains(v!), $"Supp 库位 {v} 不得出现在下拉"));
  157. // ③ 无重复
  158. Assert.Equal(optionVals.Count, optionVals.Distinct(StringComparer.OrdinalIgnoreCase).Count());
  159. }
  160. [Fact]
  161. public async Task LocationOptions_SuppLocationExcluded_NormalLocationPresent()
  162. {
  163. if (!Enabled) return;
  164. var db = BuildAidopdev();
  165. Assert.NotNull(db);
  166. var domain = await Resolver(db).ResolveDomainAsync(SourceCode, AidopTenant);
  167. var supp = (await db.Ado.SqlQueryAsync<string>(
  168. "SELECT Location FROM LocationMaster WHERE tenant_id=@T AND Domain=@D AND Typed='Supp' AND TRIM(Location)<>'' ORDER BY Location LIMIT 1",
  169. new List<SugarParameter> { new("@T", AidopTenant), new("@D", domain) })).Single();
  170. var normal = (await db.Ado.SqlQueryAsync<string>(
  171. "SELECT Location FROM LocationMaster WHERE tenant_id=@T AND Domain=@D AND IFNULL(Typed,'')<>'Supp' AND TRIM(Location)<>'' ORDER BY Location LIMIT 1",
  172. new List<SugarParameter> { new("@T", AidopTenant), new("@D", domain) })).Single();
  173. var vals = (await TenantLocationScopeLoader.LoadOptionsAsync(db, AidopTenant, domain))
  174. .Select(x => x.Val?.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase);
  175. _out.WriteLine($"Supp 样例={supp}(应缺席)· 正常样例={normal}(应存在)");
  176. Assert.DoesNotContain(supp.Trim(), vals, StringComparer.OrdinalIgnoreCase);
  177. Assert.Contains(normal.Trim(), vals, StringComparer.OrdinalIgnoreCase);
  178. }
  179. // ===== 任务 1:数据来源三态语义 =====
  180. [Fact]
  181. public async Task SourceState_NormalStd_IsNotMarkedAsLiveFallback()
  182. {
  183. if (!Enabled) return;
  184. var db = BuildAidopdev();
  185. Assert.NotNull(db);
  186. var domain = await Resolver(db).ResolveDomainAsync(SourceCode, UatTenant);
  187. var reader = new InventoryBalanceReader(
  188. Live(db), Std(db), Resolver(db), Options.Create(Opt()), NullLoggerFactory.Instance);
  189. // 无选择性筛选 → 按策略走 STD,未尝试实时源 → 不得标记为降级
  190. var page = await reader.QueryPageAsync(Q(UatTenant, domain, 1, 10));
  191. Assert.Equal(InventoryDataSources.Std, page.DataSource);
  192. Assert.False(page.IsRealtime);
  193. Assert.False(page.FallbackFromLive);
  194. _out.WriteLine($"STD_NORMAL: dataSource={page.DataSource} fallbackFromLive={page.FallbackFromLive}");
  195. }
  196. [Fact]
  197. public async Task SourceState_LiveSuccess_IsNotMarkedAsFallback()
  198. {
  199. if (!Enabled) return;
  200. var db = BuildAidopdev();
  201. Assert.NotNull(db);
  202. var domain = await Resolver(db).ResolveDomainAsync(SourceCode, UatTenant);
  203. var allowed = await TenantLocationScopeLoader.LoadAsync(db, UatTenant, domain);
  204. var reader = new InventoryBalanceReader(
  205. Live(db), Std(db), Resolver(db), Options.Create(Opt()), NullLoggerFactory.Instance);
  206. var page = await reader.QueryPageAsync(Q(UatTenant, domain, 1, 10, location: allowed.First()));
  207. Assert.Equal(InventoryDataSources.Live165, page.DataSource);
  208. Assert.True(page.IsRealtime);
  209. Assert.False(page.FallbackFromLive);
  210. _out.WriteLine($"LIVE: dataSource={page.DataSource} fallbackFromLive={page.FallbackFromLive}");
  211. }
  212. [Fact]
  213. public async Task SourceState_LiveFailure_IsMarkedAsFallback()
  214. {
  215. if (!Enabled) return;
  216. var db = BuildAidopdev();
  217. Assert.NotNull(db);
  218. var domain = await Resolver(db).ResolveDomainAsync(SourceCode, UatTenant);
  219. var allowed = await TenantLocationScopeLoader.LoadAsync(db, UatTenant, domain);
  220. // 故障注入:给 LIVE 一个 mdp_source 中不存在的源码 → 必然抛错 → 走 fallback
  221. var reader = new InventoryBalanceReader(
  222. Live(db, "NO_SUCH_SOURCE_FOR_FAULT_INJECTION"), Std(db), Resolver(db),
  223. Options.Create(Opt()), NullLoggerFactory.Instance);
  224. var page = await reader.QueryPageAsync(Q(UatTenant, domain, 1, 10, location: allowed.First()));
  225. Assert.Equal(InventoryDataSources.Std, page.DataSource);
  226. Assert.False(page.IsRealtime);
  227. Assert.True(page.FallbackFromLive); // 只有这种情况前端才提示「实时库不可用」
  228. _out.WriteLine($"STD_FALLBACK: dataSource={page.DataSource} fallbackFromLive={page.FallbackFromLive} total={page.Total}");
  229. }
  230. }