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}");
}
}