| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272 |
- 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;
- /// <summary>
- /// S5 库存查询页面收口:分页稳定性 · 库位下拉口径 · 数据来源三态语义。
- /// **门禁**:仅当环境变量 AIDOP_IT=1 时执行。全程只读。
- /// </summary>
- [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}";
- /// <summary>按 pageSize 抓完整分页,返回 (total, 抓到的全部行)。</summary>
- private static async Task<(int Total, List<InventoryBalanceRow> Rows)> FetchAllAsync(
- StdInventorySource std, InventoryBalanceQuery seed, int pageSize)
- {
- var rows = new List<InventoryBalanceRow>();
- 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<string> 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<string>(
- "SELECT DISTINCT Location FROM LocationMaster WHERE tenant_id=@T AND Domain=@D AND Typed='Supp' AND TRIM(Location)<>''",
- new List<SugarParameter> { 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<string>(
- "SELECT Location FROM LocationMaster WHERE tenant_id=@T AND Domain=@D AND Typed='Supp' AND TRIM(Location)<>'' ORDER BY Location LIMIT 1",
- new List<SugarParameter> { new("@T", AidopTenant), new("@D", domain) })).Single();
- var normal = (await db.Ado.SqlQueryAsync<string>(
- "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<SugarParameter> { 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}");
- }
- }
|