using System.Text.RegularExpressions; using Admin.NET.Plugin.AiDOP.DataPlatform; using Admin.NET.Plugin.AiDOP.Infrastructure; using Admin.NET.Plugin.AiDOP.MaterialWarehouse; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using SqlSugar; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse; /// /// S5 库存查询页面收口:分页稳定性 · 库位下拉口径 · 数据来源三态语义。 /// **门禁**:仅当环境变量 AIDOP_IT=1 时执行。全程只读。 /// [Trait("Category", "Integration")] public class StockQueryUiAndPaginationTests { private const long UatTenant = 838257186181189L; private const long AidopTenant = 797403760988229L; private const string SourceCode = "DOPDEMORQ_SQLSERVER"; private static bool Enabled => Environment.GetEnvironmentVariable("AIDOP_IT") == "1"; private readonly Xunit.Abstractions.ITestOutputHelper _out; public StockQueryUiAndPaginationTests(Xunit.Abstractions.ITestOutputHelper output) => _out = output; private static ISqlSugarClient BuildAidopdev() { const string path = "/home/yy968/work/New9S/AiDOPWarehouse/server/Admin.NET.Application/Configuration/Database.json"; if (!File.Exists(path)) return null; var cs = File.ReadLines(path) .Select(l => l.Trim()) .Where(l => !l.StartsWith("//") && l.Contains("\"ConnectionString\"") && l.Contains("Database=aidopdev") && l.Contains("123.60.180.165")) .Select(l => Regex.Match(l, "\"ConnectionString\"\\s*:\\s*\"([^\"]+)\"").Groups[1].Value) .FirstOrDefault(v => !string.IsNullOrEmpty(v)); if (string.IsNullOrEmpty(cs)) return null; return new SqlSugarScope(new ConnectionConfig { ConfigId = "aidopdev-stockquery-ui-it", DbType = DbType.MySql, ConnectionString = cs, IsAutoCloseConnection = true, }); } private static AidopInventoryOptions Opt(string sourceCode = SourceCode) => new() { SourceCode = sourceCode }; private static SourceDomainTenantResolver Resolver(ISqlSugarClient db) => new(db); private static StdInventorySource Std(ISqlSugarClient db) => new(db, Options.Create(Opt())); private static Live165InventorySource Live(ISqlSugarClient db, string sourceCode = SourceCode) => new(new MdpSourceScopeFactory(db), db, Options.Create(Opt(sourceCode))); private static InventoryBalanceQuery Q(long tenantId, string domain, int page, int pageSize, string location = null, string materialCode = null) => new() { TenantId = tenantId, Domain = domain, Location = location, MaterialCode = materialCode, Page = page, PageSize = pageSize }; private static string RowKey(InventoryBalanceRow r) => $"{r.Location}|{r.MaterialCode}|{r.BatchNo}|{r.UnrestrictedStock}|{r.QcStock}|{r.FrozenStock}|{r.QtyOnHand}"; /// 按 pageSize 抓完整分页,返回 (total, 抓到的全部行)。 private static async Task<(int Total, List Rows)> FetchAllAsync( StdInventorySource std, InventoryBalanceQuery seed, int pageSize) { var rows = new List(); var total = 0; for (var page = 1; page <= 500; page++) { var p = await std.QueryPageAsync(Q(seed.TenantId, seed.Domain, page, pageSize, seed.Location, seed.MaterialCode)); total = p.Total; rows.AddRange(p.List); if (page * pageSize >= p.Total) break; } return (total, rows); } // ===== 任务 2 · Case 1 / 2:抓取行数 == total,且去重后 == total ===== [Fact] public async Task Case1_2_StdPagination_FetchedAndUniqueEqualTotal() { if (!Enabled) return; var db = BuildAidopdev(); Assert.NotNull(db); var domain = await Resolver(db).ResolveDomainAsync(SourceCode, UatTenant); var (total, rows) = await FetchAllAsync(Std(db), Q(UatTenant, domain, 1, 50), 50); var unique = rows.Select(RowKey).Distinct().Count(); _out.WriteLine($"pageSize=50 total={total} fetched={rows.Count} unique={unique}"); Assert.True(total > 0, "STD 应有数据,否则本用例无意义"); Assert.Equal(total, rows.Count); Assert.Equal(total, unique); } [Theory] [InlineData(10)] [InlineData(50)] [InlineData(200)] public async Task Case1_StdPagination_StableAcrossPageSizes(int pageSize) { if (!Enabled) return; var db = BuildAidopdev(); Assert.NotNull(db); var domain = await Resolver(db).ResolveDomainAsync(SourceCode, UatTenant); var (total, rows) = await FetchAllAsync(Std(db), Q(UatTenant, domain, 1, pageSize), pageSize); _out.WriteLine($"pageSize={pageSize} total={total} fetched={rows.Count}"); Assert.Equal(total, rows.Count); } // ===== 任务 2 · Case 3:重复 3 次结果集合一致 ===== [Fact] public async Task Case3_StdPagination_RepeatableAcrossThreeRuns() { if (!Enabled) return; var db = BuildAidopdev(); Assert.NotNull(db); var domain = await Resolver(db).ResolveDomainAsync(SourceCode, UatTenant); List first = null; for (var run = 1; run <= 3; run++) { var (_, rows) = await FetchAllAsync(Std(db), Q(UatTenant, domain, 1, 50), 50); var keys = rows.Select(RowKey).OrderBy(x => x, StringComparer.Ordinal).ToList(); if (first is null) first = keys; else Assert.Equal(first, keys); } _out.WriteLine($"3 轮抓取结果集合一致,rows={first!.Count}"); } // ===== 任务 2 · Case 4:合法 Location 筛选下同样稳定 ===== [Fact] public async Task Case4_StdPagination_StableUnderLocationFilter() { if (!Enabled) return; var db = BuildAidopdev(); Assert.NotNull(db); var domain = await Resolver(db).ResolveDomainAsync(SourceCode, UatTenant); var allowed = await TenantLocationScopeLoader.LoadAsync(db, UatTenant, domain); var hit = 0; foreach (var loc in allowed) { var (total, rows) = await FetchAllAsync(Std(db), Q(UatTenant, domain, 1, 10, location: loc), 10); Assert.Equal(total, rows.Count); Assert.Equal(total, rows.Select(RowKey).Distinct().Count()); if (total > 0) hit++; } _out.WriteLine($"按库位逐个校验分页稳定,有数据的库位数={hit}/{allowed.Count}"); Assert.True(hit > 0); } // ===== 任务 3:库位下拉必须与查询层同口径(排除 Supp、去重)===== [Theory] [InlineData(UatTenant)] [InlineData(AidopTenant)] public async Task LocationOptions_MatchesQueryScope_AndExcludesSupp(long tenantId) { if (!Enabled) return; var db = BuildAidopdev(); Assert.NotNull(db); var domain = await Resolver(db).ResolveDomainAsync(SourceCode, tenantId); var options = await TenantLocationScopeLoader.LoadOptionsAsync(db, tenantId, domain); var scope = await TenantLocationScopeLoader.LoadAsync(db, tenantId, domain); var supp = (await db.Ado.SqlQueryAsync( "SELECT DISTINCT Location FROM LocationMaster WHERE tenant_id=@T AND Domain=@D AND Typed='Supp' AND TRIM(Location)<>''", new List { new("@T", tenantId), new("@D", domain) })) .Select(x => x.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase); var optionVals = options.Select(x => x.Val?.Trim()).Where(x => !string.IsNullOrEmpty(x)).ToList(); _out.WriteLine($"tenant={tenantId} domain={domain} options={optionVals.Count} scope={scope.Count} suppInMaster={supp.Count}"); // ① 下拉集合 == 查询层白名单集合 Assert.Equal( scope.Select(x => x.Trim()).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList(), optionVals.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList()); // ② 无 Supp Assert.All(optionVals, v => Assert.False(supp.Contains(v!), $"Supp 库位 {v} 不得出现在下拉")); // ③ 无重复 Assert.Equal(optionVals.Count, optionVals.Distinct(StringComparer.OrdinalIgnoreCase).Count()); } [Fact] public async Task LocationOptions_SuppLocationExcluded_NormalLocationPresent() { if (!Enabled) return; var db = BuildAidopdev(); Assert.NotNull(db); var domain = await Resolver(db).ResolveDomainAsync(SourceCode, AidopTenant); var supp = (await db.Ado.SqlQueryAsync( "SELECT Location FROM LocationMaster WHERE tenant_id=@T AND Domain=@D AND Typed='Supp' AND TRIM(Location)<>'' ORDER BY Location LIMIT 1", new List { new("@T", AidopTenant), new("@D", domain) })).Single(); var normal = (await db.Ado.SqlQueryAsync( "SELECT Location FROM LocationMaster WHERE tenant_id=@T AND Domain=@D AND IFNULL(Typed,'')<>'Supp' AND TRIM(Location)<>'' ORDER BY Location LIMIT 1", new List { new("@T", AidopTenant), new("@D", domain) })).Single(); var vals = (await TenantLocationScopeLoader.LoadOptionsAsync(db, AidopTenant, domain)) .Select(x => x.Val?.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase); _out.WriteLine($"Supp 样例={supp}(应缺席)· 正常样例={normal}(应存在)"); Assert.DoesNotContain(supp.Trim(), vals, StringComparer.OrdinalIgnoreCase); Assert.Contains(normal.Trim(), vals, StringComparer.OrdinalIgnoreCase); } // ===== 任务 1:数据来源三态语义 ===== [Fact] public async Task SourceState_NormalStd_IsNotMarkedAsLiveFallback() { if (!Enabled) return; var db = BuildAidopdev(); Assert.NotNull(db); var domain = await Resolver(db).ResolveDomainAsync(SourceCode, UatTenant); var reader = new InventoryBalanceReader( Live(db), Std(db), Resolver(db), Options.Create(Opt()), NullLoggerFactory.Instance); // 无选择性筛选 → 按策略走 STD,未尝试实时源 → 不得标记为降级 var page = await reader.QueryPageAsync(Q(UatTenant, domain, 1, 10)); Assert.Equal(InventoryDataSources.Std, page.DataSource); Assert.False(page.IsRealtime); Assert.False(page.FallbackFromLive); _out.WriteLine($"STD_NORMAL: dataSource={page.DataSource} fallbackFromLive={page.FallbackFromLive}"); } [Fact] public async Task SourceState_LiveSuccess_IsNotMarkedAsFallback() { if (!Enabled) return; var db = BuildAidopdev(); Assert.NotNull(db); var domain = await Resolver(db).ResolveDomainAsync(SourceCode, UatTenant); var allowed = await TenantLocationScopeLoader.LoadAsync(db, UatTenant, domain); var reader = new InventoryBalanceReader( Live(db), Std(db), Resolver(db), Options.Create(Opt()), NullLoggerFactory.Instance); var page = await reader.QueryPageAsync(Q(UatTenant, domain, 1, 10, location: allowed.First())); Assert.Equal(InventoryDataSources.Live165, page.DataSource); Assert.True(page.IsRealtime); Assert.False(page.FallbackFromLive); _out.WriteLine($"LIVE: dataSource={page.DataSource} fallbackFromLive={page.FallbackFromLive}"); } [Fact] public async Task SourceState_LiveFailure_IsMarkedAsFallback() { if (!Enabled) return; var db = BuildAidopdev(); Assert.NotNull(db); var domain = await Resolver(db).ResolveDomainAsync(SourceCode, UatTenant); var allowed = await TenantLocationScopeLoader.LoadAsync(db, UatTenant, domain); // 故障注入:给 LIVE 一个 mdp_source 中不存在的源码 → 必然抛错 → 走 fallback var reader = new InventoryBalanceReader( Live(db, "NO_SUCH_SOURCE_FOR_FAULT_INJECTION"), Std(db), Resolver(db), Options.Create(Opt()), NullLoggerFactory.Instance); var page = await reader.QueryPageAsync(Q(UatTenant, domain, 1, 10, location: allowed.First())); Assert.Equal(InventoryDataSources.Std, page.DataSource); Assert.False(page.IsRealtime); Assert.True(page.FallbackFromLive); // 只有这种情况前端才提示「实时库不可用」 _out.WriteLine($"STD_FALLBACK: dataSource={page.DataSource} fallbackFromLive={page.FallbackFromLive} total={page.Total}"); } }