Перевести тестовые фейки на моки NSubstitute #13

Merged
rust merged 12 commits from t1_NSubstitute_tests into main 2026-09-13 03:28:11 +03:00
7 changed files with 115 additions and 125 deletions
Showing only changes of commit a3b193e618 - Show all commits
@@ -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));
}
}