Перевести тестовые фейки на моки NSubstitute #13
@@ -39,14 +39,14 @@ public sealed class MlOutboxFlushSchedulerTests
|
|||||||
{
|
{
|
||||||
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
|
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
|
||||||
{
|
{
|
||||||
var store = new FakeMlLearningStore();
|
var store = new TestMlLearningStore();
|
||||||
SeedRows(store, count: 25, prefix: "a");
|
SeedRows(store, count: 25, prefix: "a");
|
||||||
var tenantContext = new TenantContext();
|
var tenantContext = new TenantContext();
|
||||||
await using ServiceProvider provider = BuildProvider(
|
await using ServiceProvider provider = BuildProvider(
|
||||||
port,
|
port,
|
||||||
new TestTenantRepository(Tenant(TenantA)),
|
new TestTenantRepository(Tenant(TenantA)),
|
||||||
tenantContext,
|
tenantContext,
|
||||||
new Dictionary<Guid, FakeMlLearningStore> { [TenantA] = store });
|
new Dictionary<Guid, TestMlLearningStore> { [TenantA] = store });
|
||||||
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
|
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
|
||||||
|
|
||||||
await scheduler.RunCycleAsync(CancellationToken.None);
|
await scheduler.RunCycleAsync(CancellationToken.None);
|
||||||
@@ -54,7 +54,7 @@ public sealed class MlOutboxFlushSchedulerTests
|
|||||||
// 25 строк → 3 батча по 10/10/5 (ml_client.flush_outbox: chunk=10), строки удалены после успеха.
|
// 25 строк → 3 батча по 10/10/5 (ml_client.flush_outbox: chunk=10), строки удалены после успеха.
|
||||||
Assert.Equal(3, service.TrainCalls);
|
Assert.Equal(3, service.TrainCalls);
|
||||||
Assert.Equal(new[] { 10, 10, 5 }, service.TrainBatches.Select(batch => batch.Items.Count).ToArray());
|
Assert.Equal(new[] { 10, 10, 5 }, service.TrainBatches.Select(batch => batch.Items.Count).ToArray());
|
||||||
Assert.Equal(0, await store.CountOutboxAsync(CancellationToken.None));
|
Assert.Equal(0, await store.Store.CountOutboxAsync(CancellationToken.None));
|
||||||
Assert.False(tenantContext.HasTenant); // контекст AsyncLocal не переживает проход
|
Assert.False(tenantContext.HasTenant); // контекст AsyncLocal не переживает проход
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -65,23 +65,23 @@ public sealed class MlOutboxFlushSchedulerTests
|
|||||||
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
|
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
|
||||||
{
|
{
|
||||||
service.TrainUnavailable = true;
|
service.TrainUnavailable = true;
|
||||||
var store = new FakeMlLearningStore();
|
var store = new TestMlLearningStore();
|
||||||
SeedRows(store, count: 5, prefix: "a");
|
SeedRows(store, count: 5, prefix: "a");
|
||||||
var tenantContext = new TenantContext();
|
var tenantContext = new TenantContext();
|
||||||
await using ServiceProvider provider = BuildProvider(
|
await using ServiceProvider provider = BuildProvider(
|
||||||
port,
|
port,
|
||||||
new TestTenantRepository(Tenant(TenantA)),
|
new TestTenantRepository(Tenant(TenantA)),
|
||||||
tenantContext,
|
tenantContext,
|
||||||
new Dictionary<Guid, FakeMlLearningStore> { [TenantA] = store });
|
new Dictionary<Guid, TestMlLearningStore> { [TenantA] = store });
|
||||||
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
|
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
|
||||||
|
|
||||||
await scheduler.RunCycleAsync(CancellationToken.None);
|
await scheduler.RunCycleAsync(CancellationToken.None);
|
||||||
Assert.Equal(5, await store.CountOutboxAsync(CancellationToken.None)); // строки остались
|
Assert.Equal(5, await store.Store.CountOutboxAsync(CancellationToken.None)); // строки остались
|
||||||
|
|
||||||
// Следующий цикл — повторная попытка (ретрай на каждом тике; строки снова не удалены).
|
// Следующий цикл — повторная попытка (ретрай на каждом тике; строки снова не удалены).
|
||||||
await scheduler.RunCycleAsync(CancellationToken.None);
|
await scheduler.RunCycleAsync(CancellationToken.None);
|
||||||
Assert.Equal(2, service.TrainCalls);
|
Assert.Equal(2, service.TrainCalls);
|
||||||
Assert.Equal(5, await store.CountOutboxAsync(CancellationToken.None));
|
Assert.Equal(5, await store.Store.CountOutboxAsync(CancellationToken.None));
|
||||||
Assert.False(tenantContext.HasTenant);
|
Assert.False(tenantContext.HasTenant);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -91,16 +91,16 @@ public sealed class MlOutboxFlushSchedulerTests
|
|||||||
{
|
{
|
||||||
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
|
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
|
||||||
{
|
{
|
||||||
var storeA = new FakeMlLearningStore();
|
var storeA = new TestMlLearningStore();
|
||||||
SeedRows(storeA, count: 12, prefix: "a");
|
SeedRows(storeA, count: 12, prefix: "a");
|
||||||
var storeB = new FakeMlLearningStore();
|
var storeB = new TestMlLearningStore();
|
||||||
SeedRows(storeB, count: 3, prefix: "b");
|
SeedRows(storeB, count: 3, prefix: "b");
|
||||||
var tenantContext = new TenantContext();
|
var tenantContext = new TenantContext();
|
||||||
await using ServiceProvider provider = BuildProvider(
|
await using ServiceProvider provider = BuildProvider(
|
||||||
port,
|
port,
|
||||||
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)),
|
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)),
|
||||||
tenantContext,
|
tenantContext,
|
||||||
new Dictionary<Guid, FakeMlLearningStore> { [TenantA] = storeA, [TenantB] = storeB });
|
new Dictionary<Guid, TestMlLearningStore> { [TenantA] = storeA, [TenantB] = storeB });
|
||||||
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
|
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
|
||||||
|
|
||||||
await scheduler.RunCycleAsync(CancellationToken.None);
|
await scheduler.RunCycleAsync(CancellationToken.None);
|
||||||
@@ -108,8 +108,8 @@ public sealed class MlOutboxFlushSchedulerTests
|
|||||||
// Каждый тенант выгрузил свою очередь в собственном scope (A: 10+2, B: 3) — строки удалены.
|
// Каждый тенант выгрузил свою очередь в собственном scope (A: 10+2, B: 3) — строки удалены.
|
||||||
Assert.Equal(3, service.TrainCalls);
|
Assert.Equal(3, service.TrainCalls);
|
||||||
Assert.Equal(new[] { 10, 2, 3 }, service.TrainBatches.Select(batch => batch.Items.Count).ToArray());
|
Assert.Equal(new[] { 10, 2, 3 }, service.TrainBatches.Select(batch => batch.Items.Count).ToArray());
|
||||||
Assert.Equal(0, await storeA.CountOutboxAsync(CancellationToken.None));
|
Assert.Equal(0, await storeA.Store.CountOutboxAsync(CancellationToken.None));
|
||||||
Assert.Equal(0, await storeB.CountOutboxAsync(CancellationToken.None));
|
Assert.Equal(0, await storeB.Store.CountOutboxAsync(CancellationToken.None));
|
||||||
Assert.False(tenantContext.HasTenant);
|
Assert.False(tenantContext.HasTenant);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -119,20 +119,20 @@ public sealed class MlOutboxFlushSchedulerTests
|
|||||||
{
|
{
|
||||||
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
|
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
|
||||||
{
|
{
|
||||||
var store = new FakeMlLearningStore();
|
var store = new TestMlLearningStore();
|
||||||
SeedRows(store, count: 105, prefix: "a");
|
SeedRows(store, count: 105, prefix: "a");
|
||||||
var tenantContext = new TenantContext();
|
var tenantContext = new TenantContext();
|
||||||
await using ServiceProvider provider = BuildProvider(
|
await using ServiceProvider provider = BuildProvider(
|
||||||
port,
|
port,
|
||||||
new TestTenantRepository(Tenant(TenantA)),
|
new TestTenantRepository(Tenant(TenantA)),
|
||||||
tenantContext,
|
tenantContext,
|
||||||
new Dictionary<Guid, FakeMlLearningStore> { [TenantA] = store });
|
new Dictionary<Guid, TestMlLearningStore> { [TenantA] = store });
|
||||||
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
|
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
|
||||||
|
|
||||||
await scheduler.RunCycleAsync(CancellationToken.None);
|
await scheduler.RunCycleAsync(CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(10, service.TrainCalls);
|
Assert.Equal(10, service.TrainCalls);
|
||||||
Assert.Equal(5, await store.CountOutboxAsync(CancellationToken.None));
|
Assert.Equal(5, await store.Store.CountOutboxAsync(CancellationToken.None));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ public sealed class MlOutboxFlushSchedulerTests
|
|||||||
int port,
|
int port,
|
||||||
TestTenantRepository tenants,
|
TestTenantRepository tenants,
|
||||||
TenantContext tenantContext,
|
TenantContext tenantContext,
|
||||||
Dictionary<Guid, FakeMlLearningStore> storesByTenant)
|
Dictionary<Guid, TestMlLearningStore> storesByTenant)
|
||||||
{
|
{
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
services.AddSingleton<ITenantContext>(tenantContext);
|
services.AddSingleton<ITenantContext>(tenantContext);
|
||||||
@@ -162,7 +162,7 @@ public sealed class MlOutboxFlushSchedulerTests
|
|||||||
services.AddScoped<IMlClient>(provider => provider.GetRequiredService<GrpcMlClient>());
|
services.AddScoped<IMlClient>(provider => provider.GetRequiredService<GrpcMlClient>());
|
||||||
services.AddScoped<IMlTrainClient>(provider => provider.GetRequiredService<GrpcMlClient>());
|
services.AddScoped<IMlTrainClient>(provider => provider.GetRequiredService<GrpcMlClient>());
|
||||||
// Tenant-scoped адаптеры: выбирают фейк по тому же ITenantContext, который планировщик заполняет SetTenant.
|
// Tenant-scoped адаптеры: выбирают фейк по тому же ITenantContext, который планировщик заполняет SetTenant.
|
||||||
services.AddScoped<IMlLearningStore>(provider => storesByTenant[TenantOf(provider)]);
|
services.AddScoped<IMlLearningStore>(provider => storesByTenant[TenantOf(provider)].Store);
|
||||||
services.AddScoped<ISettingsStore>(_ => new TestSettingsStore().Store);
|
services.AddScoped<ISettingsStore>(_ => new TestSettingsStore().Store);
|
||||||
services.AddScoped<ITenantLimitStore>(_ => new TestTenantLimitStore().Store);
|
services.AddScoped<ITenantLimitStore>(_ => new TestTenantLimitStore().Store);
|
||||||
services.AddScoped<TokenUsageEventService>(_ => new TokenUsageEventService(new TestTokenUsageEventStore().Store));
|
services.AddScoped<TokenUsageEventService>(_ => new TokenUsageEventService(new TestTokenUsageEventStore().Store));
|
||||||
@@ -196,7 +196,7 @@ public sealed class MlOutboxFlushSchedulerTests
|
|||||||
// count: Число строк.
|
// count: Число строк.
|
||||||
// prefix: Префикс id (различает тенантов теста).
|
// prefix: Префикс id (различает тенантов теста).
|
||||||
private static void SeedRows(
|
private static void SeedRows(
|
||||||
FakeMlLearningStore store,
|
TestMlLearningStore store,
|
||||||
int count,
|
int count,
|
||||||
string prefix)
|
string prefix)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -38,10 +38,10 @@ public sealed class RuntimeDepthsCollectorTests
|
|||||||
pipelineByTenant[TenantA.ToString("N")].SeedQueue(QueueItem("p_a2"));
|
pipelineByTenant[TenantA.ToString("N")].SeedQueue(QueueItem("p_a2"));
|
||||||
pipelineByTenant[TenantB.ToString("N")].SeedQueue(QueueItem("p_b1"));
|
pipelineByTenant[TenantB.ToString("N")].SeedQueue(QueueItem("p_b1"));
|
||||||
|
|
||||||
var outboxByTenant = new Dictionary<string, FakeMlLearningStore>
|
var outboxByTenant = new Dictionary<string, TestMlLearningStore>
|
||||||
{
|
{
|
||||||
[TenantA.ToString("N")] = new FakeMlLearningStore(),
|
[TenantA.ToString("N")] = new TestMlLearningStore(),
|
||||||
[TenantB.ToString("N")] = new FakeMlLearningStore(),
|
[TenantB.ToString("N")] = new TestMlLearningStore(),
|
||||||
};
|
};
|
||||||
outboxByTenant[TenantA.ToString("N")].SeedOutbox("mle_1", "текст", "spam");
|
outboxByTenant[TenantA.ToString("N")].SeedOutbox("mle_1", "текст", "spam");
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ public sealed class RuntimeDepthsCollectorTests
|
|||||||
{
|
{
|
||||||
RuntimeDepthsCollector collector = Build(
|
RuntimeDepthsCollector collector = Build(
|
||||||
new Dictionary<string, FakePipelineStore>(),
|
new Dictionary<string, FakePipelineStore>(),
|
||||||
new Dictionary<string, FakeMlLearningStore>(),
|
new Dictionary<string, TestMlLearningStore>(),
|
||||||
tenants: Array.Empty<TenantRecordDto>());
|
tenants: Array.Empty<TenantRecordDto>());
|
||||||
|
|
||||||
RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None);
|
RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None);
|
||||||
@@ -126,8 +126,8 @@ public sealed class RuntimeDepthsCollectorTests
|
|||||||
private static Dictionary<string, FakePipelineStore> PipelineStores(params Guid[] tenants)
|
private static Dictionary<string, FakePipelineStore> PipelineStores(params Guid[] tenants)
|
||||||
=> tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new FakePipelineStore());
|
=> tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new FakePipelineStore());
|
||||||
|
|
||||||
private static Dictionary<string, FakeMlLearningStore> OutboxStores(params Guid[] tenants)
|
private static Dictionary<string, TestMlLearningStore> OutboxStores(params Guid[] tenants)
|
||||||
=> tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new FakeMlLearningStore());
|
=> tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new TestMlLearningStore());
|
||||||
|
|
||||||
private static TenantRecordDto[] TenantRecords(params Guid[] tenants)
|
private static TenantRecordDto[] TenantRecords(params Guid[] tenants)
|
||||||
=> tenants
|
=> tenants
|
||||||
@@ -140,7 +140,7 @@ public sealed class RuntimeDepthsCollectorTests
|
|||||||
// Собирает коллектор поверх tenant-scoped фейков (как реальные адаптеры по ITenantContext).
|
// Собирает коллектор поверх tenant-scoped фейков (как реальные адаптеры по ITenantContext).
|
||||||
private static RuntimeDepthsCollector Build(
|
private static RuntimeDepthsCollector Build(
|
||||||
IReadOnlyDictionary<string, FakePipelineStore> pipelineByTenant,
|
IReadOnlyDictionary<string, FakePipelineStore> pipelineByTenant,
|
||||||
IReadOnlyDictionary<string, FakeMlLearningStore> outboxByTenant,
|
IReadOnlyDictionary<string, TestMlLearningStore> outboxByTenant,
|
||||||
IReadOnlyList<TenantRecordDto>? tenants = null,
|
IReadOnlyList<TenantRecordDto>? tenants = null,
|
||||||
ITenantLimitStore? limitStore = null)
|
ITenantLimitStore? limitStore = null)
|
||||||
{
|
{
|
||||||
@@ -157,7 +157,7 @@ public sealed class RuntimeDepthsCollectorTests
|
|||||||
services.AddScoped<IMlClient>(_ => new TestMlClient().Client);
|
services.AddScoped<IMlClient>(_ => new TestMlClient().Client);
|
||||||
services.AddScoped<PipelineIngestService>();
|
services.AddScoped<PipelineIngestService>();
|
||||||
services.AddScoped<PipelineProcessingService>();
|
services.AddScoped<PipelineProcessingService>();
|
||||||
services.AddScoped<IMlLearningStore>(provider => outboxByTenant[CurrentTenant(provider)]);
|
services.AddScoped<IMlLearningStore>(provider => outboxByTenant[CurrentTenant(provider)].Store);
|
||||||
services.AddSingleton<ITenantLimitStore>(limitStore ?? new TestTenantLimitStore().Store);
|
services.AddSingleton<ITenantLimitStore>(limitStore ?? new TestTenantLimitStore().Store);
|
||||||
|
|
||||||
ServiceProvider provider = services.BuildServiceProvider();
|
ServiceProvider provider = services.BuildServiceProvider();
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ public sealed class GrpcMlClientTests
|
|||||||
var settings = new TestSettingsStore();
|
var settings = new TestSettingsStore();
|
||||||
settings.Preload(SettingsKeys.MlDecisions, "7");
|
settings.Preload(SettingsKeys.MlDecisions, "7");
|
||||||
settings.Preload(SettingsKeys.AiDecisions, "3");
|
settings.Preload(SettingsKeys.AiDecisions, "3");
|
||||||
var learning = new FakeMlLearningStore { LearningCount = 9 };
|
var learning = new TestMlLearningStore { LearningCount = 9 };
|
||||||
|
|
||||||
IMlClient client = CreateClient(port, settings, learning);
|
IMlClient client = CreateClient(port, settings, learning);
|
||||||
MlStatusResponseDto status = await client.StatusAsync(CancellationToken.None);
|
MlStatusResponseDto status = await client.StatusAsync(CancellationToken.None);
|
||||||
@@ -195,7 +195,7 @@ public sealed class GrpcMlClientTests
|
|||||||
{
|
{
|
||||||
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
|
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
|
||||||
{
|
{
|
||||||
var learning = new FakeMlLearningStore();
|
var learning = new TestMlLearningStore();
|
||||||
learning.SeedOutbox("mle_1", "текст 1", "b_a", 1.0);
|
learning.SeedOutbox("mle_1", "текст 1", "b_a", 1.0);
|
||||||
learning.SeedOutbox("mle_2", "текст 2", "spam", 1.0);
|
learning.SeedOutbox("mle_2", "текст 2", "spam", 1.0);
|
||||||
IMlClient client = CreateClient(port, learning: learning);
|
IMlClient client = CreateClient(port, learning: learning);
|
||||||
@@ -206,7 +206,7 @@ public sealed class GrpcMlClientTests
|
|||||||
|
|
||||||
Assert.True(reset.Ok);
|
Assert.True(reset.Ok);
|
||||||
Assert.Null(reset.Error);
|
Assert.Null(reset.Error);
|
||||||
Assert.Equal(0, await learning.CountOutboxAsync(CancellationToken.None)); // очередь очищена
|
Assert.Equal(0, await learning.Store.CountOutboxAsync(CancellationToken.None)); // очередь очищена
|
||||||
Assert.Equal(1, service.ResetCalls);
|
Assert.Equal(1, service.ResetCalls);
|
||||||
|
|
||||||
_ = await client.StatusAsync(CancellationToken.None);
|
_ = await client.StatusAsync(CancellationToken.None);
|
||||||
@@ -220,7 +220,7 @@ public sealed class GrpcMlClientTests
|
|||||||
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
|
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
|
||||||
{
|
{
|
||||||
service.ResetReply = new ResetReply { Ok = false, Error = "не удалось пересоздать файл модели" };
|
service.ResetReply = new ResetReply { Ok = false, Error = "не удалось пересоздать файл модели" };
|
||||||
var learning = new FakeMlLearningStore();
|
var learning = new TestMlLearningStore();
|
||||||
learning.SeedOutbox("mle_1", "текст", "b_a", 1.0);
|
learning.SeedOutbox("mle_1", "текст", "b_a", 1.0);
|
||||||
|
|
||||||
IMlClient client = CreateClient(port, learning: learning);
|
IMlClient client = CreateClient(port, learning: learning);
|
||||||
@@ -228,7 +228,7 @@ public sealed class GrpcMlClientTests
|
|||||||
|
|
||||||
Assert.False(reset.Ok);
|
Assert.False(reset.Ok);
|
||||||
Assert.Equal("не удалось пересоздать файл модели", reset.Error);
|
Assert.Equal("не удалось пересоздать файл модели", reset.Error);
|
||||||
Assert.Equal(1, await learning.CountOutboxAsync(CancellationToken.None));
|
Assert.Equal(1, await learning.Store.CountOutboxAsync(CancellationToken.None));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,7 +238,7 @@ public sealed class GrpcMlClientTests
|
|||||||
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
|
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
|
||||||
{
|
{
|
||||||
service.ResetUnavailable = true;
|
service.ResetUnavailable = true;
|
||||||
var learning = new FakeMlLearningStore();
|
var learning = new TestMlLearningStore();
|
||||||
learning.SeedOutbox("mle_1", "текст", "spam", 1.0);
|
learning.SeedOutbox("mle_1", "текст", "spam", 1.0);
|
||||||
|
|
||||||
IMlClient client = CreateClient(port, learning: learning);
|
IMlClient client = CreateClient(port, learning: learning);
|
||||||
@@ -246,7 +246,7 @@ public sealed class GrpcMlClientTests
|
|||||||
|
|
||||||
Assert.False(reset.Ok);
|
Assert.False(reset.Ok);
|
||||||
Assert.Equal("ML-сервис недоступен", reset.Error);
|
Assert.Equal("ML-сервис недоступен", reset.Error);
|
||||||
Assert.Equal(1, await learning.CountOutboxAsync(CancellationToken.None));
|
Assert.Equal(1, await learning.Store.CountOutboxAsync(CancellationToken.None));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,7 +256,7 @@ public sealed class GrpcMlClientTests
|
|||||||
{
|
{
|
||||||
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
|
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
|
||||||
{
|
{
|
||||||
var learning = new FakeMlLearningStore();
|
var learning = new TestMlLearningStore();
|
||||||
IMlClient client = CreateClient(port, learning: learning);
|
IMlClient client = CreateClient(port, learning: learning);
|
||||||
|
|
||||||
await client.PushAsync(" нужен python ", "b_junior", 1.0, CancellationToken.None);
|
await client.PushAsync(" нужен python ", "b_junior", 1.0, CancellationToken.None);
|
||||||
@@ -310,17 +310,18 @@ public sealed class GrpcMlClientTests
|
|||||||
private static GrpcMlClient CreateClient(
|
private static GrpcMlClient CreateClient(
|
||||||
int port,
|
int port,
|
||||||
TestSettingsStore? settings = null,
|
TestSettingsStore? settings = null,
|
||||||
FakeMlLearningStore? learning = null,
|
TestMlLearningStore? learning = null,
|
||||||
MlStatusCache? cache = null)
|
MlStatusCache? cache = null)
|
||||||
{
|
{
|
||||||
settings ??= new TestSettingsStore();
|
settings ??= new TestSettingsStore();
|
||||||
|
learning ??= new TestMlLearningStore();
|
||||||
ITenantContext tenantContext = new TenantContext();
|
ITenantContext tenantContext = new TenantContext();
|
||||||
tenantContext.SetTenant(new TenantId(TenantIdValue));
|
tenantContext.SetTenant(new TenantId(TenantIdValue));
|
||||||
var options = new MlServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" };
|
var options = new MlServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" };
|
||||||
return new GrpcMlClient(
|
return new GrpcMlClient(
|
||||||
tenantContext,
|
tenantContext,
|
||||||
settings.Store,
|
settings.Store,
|
||||||
learning ?? new FakeMlLearningStore(),
|
learning.Store,
|
||||||
new MlGrpcConnection(options),
|
new MlGrpcConnection(options),
|
||||||
cache ?? new MlStatusCache(),
|
cache ?? new MlStatusCache(),
|
||||||
new TokenUsageRecorder(
|
new TokenUsageRecorder(
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ public sealed class IntegrationsDiTests
|
|||||||
services.AddScoped<ISettingsStore>(_ => new TestSettingsStore().Store);
|
services.AddScoped<ISettingsStore>(_ => new TestSettingsStore().Store);
|
||||||
services.AddScoped<ISecretCipher>(_ => TestCiphers.New());
|
services.AddScoped<ISecretCipher>(_ => TestCiphers.New());
|
||||||
services.AddScoped<ICardStore>(_ => new FakeKanjStore());
|
services.AddScoped<ICardStore>(_ => new FakeKanjStore());
|
||||||
services.AddScoped<IMlLearningStore>(_ => new FakeMlLearningStore());
|
services.AddScoped<IMlLearningStore>(_ => new TestMlLearningStore().Store);
|
||||||
services.AddScoped<ITenantLimitStore>(_ => new TestTenantLimitStore().Store);
|
services.AddScoped<ITenantLimitStore>(_ => new TestTenantLimitStore().Store);
|
||||||
services.AddScoped<TokenUsageEventService>(_ => new TokenUsageEventService(new TestTokenUsageEventStore().Store));
|
services.AddScoped<TokenUsageEventService>(_ => new TokenUsageEventService(new TestTokenUsageEventStore().Store));
|
||||||
services.AddScoped<LocalFieldsParser>();
|
services.AddScoped<LocalFieldsParser>();
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
using Deal.Modules.Kanban.Application.Abstractions;
|
|
||||||
using Deal.Modules.Kanban.Application.Models;
|
|
||||||
|
|
||||||
namespace Deal.Tests.Unit.Modules.Kanban;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// In-memory реализация <see cref="IMlLearningStore"/> для unit-тестов ML-интеграции.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class FakeMlLearningStore : IMlLearningStore
|
|
||||||
{
|
|
||||||
private readonly List<(string Id, string Text, string Label, double Delta)> _rows = [];
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Число записей журнала обучения
|
|
||||||
/// </summary>
|
|
||||||
public int LearningCount { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Строки очереди
|
|
||||||
/// </summary>
|
|
||||||
public IReadOnlyList<(string Id, string Text, string Label, double Delta)> AddedRows => _rows.ToList();
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task<int> CountLearningAsync(CancellationToken ct) => Task.FromResult(LearningCount);
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task<int> CountOutboxAsync(CancellationToken ct) => Task.FromResult(_rows.Count);
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task AddOutboxAsync(
|
|
||||||
string id,
|
|
||||||
string text,
|
|
||||||
string label,
|
|
||||||
double delta,
|
|
||||||
CancellationToken ct)
|
|
||||||
{
|
|
||||||
_rows.Add((id, text, label, delta));
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Пишет строку очереди напрямую
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">Id строки (<c>mle_...</c>).</param>
|
|
||||||
/// <param name="text">Текст обучающего примера.</param>
|
|
||||||
/// <param name="label">Метка обучения.</param>
|
|
||||||
/// <param name="delta">Вес сигнала.</param>
|
|
||||||
public void SeedOutbox(
|
|
||||||
string id,
|
|
||||||
string text,
|
|
||||||
string label,
|
|
||||||
double delta = 1.0)
|
|
||||||
{
|
|
||||||
_rows.Add((id, text, label, delta));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task ClearOutboxAsync(CancellationToken ct)
|
|
||||||
{
|
|
||||||
_rows.Clear();
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task<IReadOnlyList<MlOutboxEntryDto>> TakeOutboxBatchAsync(int limit, CancellationToken ct)
|
|
||||||
{
|
|
||||||
return Task.FromResult<IReadOnlyList<MlOutboxEntryDto>>(
|
|
||||||
_rows.Take(limit)
|
|
||||||
.Select(row => new MlOutboxEntryDto(row.Id, row.Text, row.Label, row.Delta))
|
|
||||||
.ToList());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task DeleteOutboxAsync(IReadOnlyCollection<string> ids, CancellationToken ct)
|
|
||||||
{
|
|
||||||
_rows.RemoveAll(row => ids.Contains(row.Id));
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -28,8 +28,11 @@ public sealed class LocalMlClientTests
|
|||||||
// store: Хранилище настроек тенанта.
|
// store: Хранилище настроек тенанта.
|
||||||
// learning: Хранилище обучения (по умолчанию пустое: счётчики 0).
|
// learning: Хранилище обучения (по умолчанию пустое: счётчики 0).
|
||||||
// Возвращает: Экземпляр LocalMlClient.
|
// Возвращает: Экземпляр LocalMlClient.
|
||||||
private static LocalMlClient CreateClient(TestSettingsStore store, FakeMlLearningStore? learning = null)
|
private static LocalMlClient CreateClient(TestSettingsStore store, TestMlLearningStore? learning = null)
|
||||||
=> new(store.Store, learning ?? new FakeMlLearningStore());
|
{
|
||||||
|
learning ??= new TestMlLearningStore();
|
||||||
|
return new(store.Store, learning.Store);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -126,7 +129,7 @@ public sealed class LocalMlClientTests
|
|||||||
public async Task StatusAsync_ReportsLearningAndOutboxFromTables()
|
public async Task StatusAsync_ReportsLearningAndOutboxFromTables()
|
||||||
{
|
{
|
||||||
var store = new TestSettingsStore();
|
var store = new TestSettingsStore();
|
||||||
var learning = new FakeMlLearningStore { LearningCount = 3 }; // журнал CardMoves: 3 записи
|
var learning = new TestMlLearningStore { LearningCount = 3 }; // журнал CardMoves: 3 записи
|
||||||
LocalMlClient client = CreateClient(store, learning);
|
LocalMlClient client = CreateClient(store, learning);
|
||||||
await client.PushAsync("Разработчик Python, готов к проекту", "b_alpha", 1.0, CancellationToken.None);
|
await client.PushAsync("Разработчик Python, готов к проекту", "b_alpha", 1.0, CancellationToken.None);
|
||||||
await client.PushAsync("Спам: купите курсы", "spam", -1.0, CancellationToken.None);
|
await client.PushAsync("Спам: купите курсы", "spam", -1.0, CancellationToken.None);
|
||||||
@@ -162,7 +165,7 @@ public sealed class LocalMlClientTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task PushAsync_WritesOutboxRow_WithMleIdAndTrimmedFields()
|
public async Task PushAsync_WritesOutboxRow_WithMleIdAndTrimmedFields()
|
||||||
{
|
{
|
||||||
var learning = new FakeMlLearningStore();
|
var learning = new TestMlLearningStore();
|
||||||
LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
|
LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
|
||||||
|
|
||||||
await client.PushAsync(" Python-разработчик, готов к работе ", " b_alpha ", 1.0, CancellationToken.None);
|
await client.PushAsync(" Python-разработчик, готов к работе ", " b_alpha ", 1.0, CancellationToken.None);
|
||||||
@@ -182,7 +185,7 @@ public sealed class LocalMlClientTests
|
|||||||
[InlineData("текст", " ")] // label из пробелов — пуста после trim
|
[InlineData("текст", " ")] // label из пробелов — пуста после trim
|
||||||
public async Task PushAsync_EmptyTextOrLabelAfterTrim_IsNoOp(string text, string label)
|
public async Task PushAsync_EmptyTextOrLabelAfterTrim_IsNoOp(string text, string label)
|
||||||
{
|
{
|
||||||
var learning = new FakeMlLearningStore();
|
var learning = new TestMlLearningStore();
|
||||||
LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
|
LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
|
||||||
|
|
||||||
await client.PushAsync(text, label, 1.0, CancellationToken.None);
|
await client.PushAsync(text, label, 1.0, CancellationToken.None);
|
||||||
@@ -193,7 +196,7 @@ public sealed class LocalMlClientTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task PushAsync_TextOver6000_IsTruncatedToMaxLength()
|
public async Task PushAsync_TextOver6000_IsTruncatedToMaxLength()
|
||||||
{
|
{
|
||||||
var learning = new FakeMlLearningStore();
|
var learning = new TestMlLearningStore();
|
||||||
LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
|
LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
|
||||||
string longText = new string('а', MaxLearningTextLength + 1000);
|
string longText = new string('а', MaxLearningTextLength + 1000);
|
||||||
|
|
||||||
@@ -207,7 +210,7 @@ public sealed class LocalMlClientTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task PushAsync_TruncationDoesNotSplitSurrogatePair()
|
public async Task PushAsync_TruncationDoesNotSplitSurrogatePair()
|
||||||
{
|
{
|
||||||
var learning = new FakeMlLearningStore();
|
var learning = new TestMlLearningStore();
|
||||||
LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
|
LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
|
||||||
// Длина 6001 в UTF-16: 5999 'x' + «🙂» (суррогатная пара) — срез .NET на 6000 попадает в пару.
|
// Длина 6001 в UTF-16: 5999 'x' + «🙂» (суррогатная пара) — срез .NET на 6000 попадает в пару.
|
||||||
string text = new string('x', MaxLearningTextLength - 1) + "🙂";
|
string text = new string('x', MaxLearningTextLength - 1) + "🙂";
|
||||||
@@ -223,7 +226,7 @@ public sealed class LocalMlClientTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task PushAsync_NegativeDelta_IsStoredAsIs()
|
public async Task PushAsync_NegativeDelta_IsStoredAsIs()
|
||||||
{
|
{
|
||||||
var learning = new FakeMlLearningStore();
|
var learning = new TestMlLearningStore();
|
||||||
LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
|
LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
|
||||||
|
|
||||||
await client.PushAsync("Это не спам — вернул из корзины", "spam", -1.0, CancellationToken.None);
|
await client.PushAsync("Это не спам — вернул из корзины", "spam", -1.0, CancellationToken.None);
|
||||||
@@ -262,7 +265,7 @@ public sealed class LocalMlClientTests
|
|||||||
{
|
{
|
||||||
var store = new TestSettingsStore();
|
var store = new TestSettingsStore();
|
||||||
store.Preload(SettingsKeys.MlDecisions, "7");
|
store.Preload(SettingsKeys.MlDecisions, "7");
|
||||||
var learning = new FakeMlLearningStore { LearningCount = 4 }; // журнал CardMoves: 4 записи
|
var learning = new TestMlLearningStore { LearningCount = 4 }; // журнал CardMoves: 4 записи
|
||||||
LocalMlClient client = CreateClient(store, learning);
|
LocalMlClient client = CreateClient(store, learning);
|
||||||
await client.PushAsync("текст 1", "b_alpha", 1.0, CancellationToken.None);
|
await client.PushAsync("текст 1", "b_alpha", 1.0, CancellationToken.None);
|
||||||
await client.PushAsync("текст 2", "spam", -1.0, CancellationToken.None);
|
await client.PushAsync("текст 2", "spam", -1.0, CancellationToken.None);
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using Deal.Modules.Kanban.Application.Abstractions;
|
||||||
|
using Deal.Modules.Kanban.Application.Models;
|
||||||
|
using NSubstitute;
|
||||||
|
|
||||||
|
namespace Deal.Tests.Unit.Support;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Подставка <see cref="IMlLearningStore"/> на списках: сервисы получают NSubstitute-подставку
|
||||||
|
/// (<see cref="Store"/>), тесты сеют/проверяют очередь через <see cref="SeedOutbox"/> и <see cref="AddedRows"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TestMlLearningStore
|
||||||
|
{
|
||||||
|
private readonly List<(string Id, string Text, string Label, double Delta)> _rows = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Подставка порта ML-обучения (создаётся в конструкторе).
|
||||||
|
/// </summary>
|
||||||
|
public IMlLearningStore Store { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Число записей журнала обучения
|
||||||
|
/// </summary>
|
||||||
|
public int LearningCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Строки очереди
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<(string Id, string Text, string Label, double Delta)> AddedRows => _rows.ToList();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Создаёт подставку с пустой очередью.
|
||||||
|
/// </summary>
|
||||||
|
public TestMlLearningStore()
|
||||||
|
{
|
||||||
|
Store = Substitute.For<IMlLearningStore>();
|
||||||
|
Store.CountLearningAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(ci => LearningCount);
|
||||||
|
Store.CountOutboxAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(_ => _rows.Count);
|
||||||
|
Store.When(s => s.AddOutboxAsync(
|
||||||
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<double>(), Arg.Any<CancellationToken>()))
|
||||||
|
.Do(ci => _rows.Add((ci.ArgAt<string>(0), ci.ArgAt<string>(1), ci.ArgAt<string>(2), ci.ArgAt<double>(3))));
|
||||||
|
Store.When(s => s.ClearOutboxAsync(Arg.Any<CancellationToken>()))
|
||||||
|
.Do(_ => _rows.Clear());
|
||||||
|
Store.TakeOutboxBatchAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(ci => _rows
|
||||||
|
.Take(ci.ArgAt<int>(0))
|
||||||
|
.Select(row => new MlOutboxEntryDto(row.Id, row.Text, row.Label, row.Delta))
|
||||||
|
.ToList());
|
||||||
|
Store.When(s => s.DeleteOutboxAsync(Arg.Any<IReadOnlyCollection<string>>(), Arg.Any<CancellationToken>()))
|
||||||
|
.Do(ci => _rows.RemoveAll(row => ci.Arg<IReadOnlyCollection<string>>().Contains(row.Id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Пишет строку очереди напрямую
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">Id строки (<c>mle_...</c>).</param>
|
||||||
|
/// <param name="text">Текст обучающего примера.</param>
|
||||||
|
/// <param name="label">Метка обучения.</param>
|
||||||
|
/// <param name="delta">Вес сигнала.</param>
|
||||||
|
public void SeedOutbox(string id, string text, string label, double delta = 1.0)
|
||||||
|
{
|
||||||
|
_rows.Add((id, text, label, delta));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user