Перевести FakeMlLearningStore на NSubstitute

Подставка IMlLearningStore переведена на NSubstitute (TestMlLearningStore.Store); динамический CountOutboxAsync; обновлены 5 потребителей.
This commit is contained in:
2026-09-13 02:49:08 +03:00
parent d51d88ed65
commit a3b193e618
7 changed files with 115 additions and 125 deletions
@@ -39,14 +39,14 @@ public sealed class MlOutboxFlushSchedulerTests
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
var store = new FakeMlLearningStore();
var store = new TestMlLearningStore();
SeedRows(store, count: 25, prefix: "a");
var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
port,
new TestTenantRepository(Tenant(TenantA)),
tenantContext,
new Dictionary<Guid, FakeMlLearningStore> { [TenantA] = store });
new Dictionary<Guid, TestMlLearningStore> { [TenantA] = store });
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
await scheduler.RunCycleAsync(CancellationToken.None);
@@ -54,7 +54,7 @@ public sealed class MlOutboxFlushSchedulerTests
// 25 строк → 3 батча по 10/10/5 (ml_client.flush_outbox: chunk=10), строки удалены после успеха.
Assert.Equal(3, service.TrainCalls);
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 не переживает проход
});
}
@@ -65,23 +65,23 @@ public sealed class MlOutboxFlushSchedulerTests
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
service.TrainUnavailable = true;
var store = new FakeMlLearningStore();
var store = new TestMlLearningStore();
SeedRows(store, count: 5, prefix: "a");
var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
port,
new TestTenantRepository(Tenant(TenantA)),
tenantContext,
new Dictionary<Guid, FakeMlLearningStore> { [TenantA] = store });
new Dictionary<Guid, TestMlLearningStore> { [TenantA] = store });
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
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);
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);
});
}
@@ -91,16 +91,16 @@ public sealed class MlOutboxFlushSchedulerTests
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
var storeA = new FakeMlLearningStore();
var storeA = new TestMlLearningStore();
SeedRows(storeA, count: 12, prefix: "a");
var storeB = new FakeMlLearningStore();
var storeB = new TestMlLearningStore();
SeedRows(storeB, count: 3, prefix: "b");
var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
port,
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)),
tenantContext,
new Dictionary<Guid, FakeMlLearningStore> { [TenantA] = storeA, [TenantB] = storeB });
new Dictionary<Guid, TestMlLearningStore> { [TenantA] = storeA, [TenantB] = storeB });
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
await scheduler.RunCycleAsync(CancellationToken.None);
@@ -108,8 +108,8 @@ public sealed class MlOutboxFlushSchedulerTests
// Каждый тенант выгрузил свою очередь в собственном scope (A: 10+2, B: 3) — строки удалены.
Assert.Equal(3, service.TrainCalls);
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 storeB.CountOutboxAsync(CancellationToken.None));
Assert.Equal(0, await storeA.Store.CountOutboxAsync(CancellationToken.None));
Assert.Equal(0, await storeB.Store.CountOutboxAsync(CancellationToken.None));
Assert.False(tenantContext.HasTenant);
});
}
@@ -119,20 +119,20 @@ public sealed class MlOutboxFlushSchedulerTests
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
var store = new FakeMlLearningStore();
var store = new TestMlLearningStore();
SeedRows(store, count: 105, prefix: "a");
var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
port,
new TestTenantRepository(Tenant(TenantA)),
tenantContext,
new Dictionary<Guid, FakeMlLearningStore> { [TenantA] = store });
new Dictionary<Guid, TestMlLearningStore> { [TenantA] = store });
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
await scheduler.RunCycleAsync(CancellationToken.None);
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,
TestTenantRepository tenants,
TenantContext tenantContext,
Dictionary<Guid, FakeMlLearningStore> storesByTenant)
Dictionary<Guid, TestMlLearningStore> storesByTenant)
{
var services = new ServiceCollection();
services.AddSingleton<ITenantContext>(tenantContext);
@@ -162,7 +162,7 @@ public sealed class MlOutboxFlushSchedulerTests
services.AddScoped<IMlClient>(provider => provider.GetRequiredService<GrpcMlClient>());
services.AddScoped<IMlTrainClient>(provider => provider.GetRequiredService<GrpcMlClient>());
// 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<ITenantLimitStore>(_ => new TestTenantLimitStore().Store);
services.AddScoped<TokenUsageEventService>(_ => new TokenUsageEventService(new TestTokenUsageEventStore().Store));
@@ -196,7 +196,7 @@ public sealed class MlOutboxFlushSchedulerTests
// count: Число строк.
// prefix: Префикс id (различает тенантов теста).
private static void SeedRows(
FakeMlLearningStore store,
TestMlLearningStore store,
int count,
string prefix)
{
@@ -38,10 +38,10 @@ public sealed class RuntimeDepthsCollectorTests
pipelineByTenant[TenantA.ToString("N")].SeedQueue(QueueItem("p_a2"));
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(),
[TenantB.ToString("N")] = new FakeMlLearningStore(),
[TenantA.ToString("N")] = new TestMlLearningStore(),
[TenantB.ToString("N")] = new TestMlLearningStore(),
};
outboxByTenant[TenantA.ToString("N")].SeedOutbox("mle_1", "текст", "spam");
@@ -106,7 +106,7 @@ public sealed class RuntimeDepthsCollectorTests
{
RuntimeDepthsCollector collector = Build(
new Dictionary<string, FakePipelineStore>(),
new Dictionary<string, FakeMlLearningStore>(),
new Dictionary<string, TestMlLearningStore>(),
tenants: Array.Empty<TenantRecordDto>());
RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None);
@@ -126,8 +126,8 @@ public sealed class RuntimeDepthsCollectorTests
private static Dictionary<string, FakePipelineStore> PipelineStores(params Guid[] tenants)
=> tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new FakePipelineStore());
private static Dictionary<string, FakeMlLearningStore> OutboxStores(params Guid[] tenants)
=> tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new FakeMlLearningStore());
private static Dictionary<string, TestMlLearningStore> OutboxStores(params Guid[] tenants)
=> tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new TestMlLearningStore());
private static TenantRecordDto[] TenantRecords(params Guid[] tenants)
=> tenants
@@ -140,7 +140,7 @@ public sealed class RuntimeDepthsCollectorTests
// Собирает коллектор поверх tenant-scoped фейков (как реальные адаптеры по ITenantContext).
private static RuntimeDepthsCollector Build(
IReadOnlyDictionary<string, FakePipelineStore> pipelineByTenant,
IReadOnlyDictionary<string, FakeMlLearningStore> outboxByTenant,
IReadOnlyDictionary<string, TestMlLearningStore> outboxByTenant,
IReadOnlyList<TenantRecordDto>? tenants = null,
ITenantLimitStore? limitStore = null)
{
@@ -157,7 +157,7 @@ public sealed class RuntimeDepthsCollectorTests
services.AddScoped<IMlClient>(_ => new TestMlClient().Client);
services.AddScoped<PipelineIngestService>();
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);
ServiceProvider provider = services.BuildServiceProvider();
@@ -113,7 +113,7 @@ public sealed class GrpcMlClientTests
var settings = new TestSettingsStore();
settings.Preload(SettingsKeys.MlDecisions, "7");
settings.Preload(SettingsKeys.AiDecisions, "3");
var learning = new FakeMlLearningStore { LearningCount = 9 };
var learning = new TestMlLearningStore { LearningCount = 9 };
IMlClient client = CreateClient(port, settings, learning);
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) =>
{
var learning = new FakeMlLearningStore();
var learning = new TestMlLearningStore();
learning.SeedOutbox("mle_1", "текст 1", "b_a", 1.0);
learning.SeedOutbox("mle_2", "текст 2", "spam", 1.0);
IMlClient client = CreateClient(port, learning: learning);
@@ -206,7 +206,7 @@ public sealed class GrpcMlClientTests
Assert.True(reset.Ok);
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);
_ = await client.StatusAsync(CancellationToken.None);
@@ -220,7 +220,7 @@ public sealed class GrpcMlClientTests
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
service.ResetReply = new ResetReply { Ok = false, Error = "не удалось пересоздать файл модели" };
var learning = new FakeMlLearningStore();
var learning = new TestMlLearningStore();
learning.SeedOutbox("mle_1", "текст", "b_a", 1.0);
IMlClient client = CreateClient(port, learning: learning);
@@ -228,7 +228,7 @@ public sealed class GrpcMlClientTests
Assert.False(reset.Ok);
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) =>
{
service.ResetUnavailable = true;
var learning = new FakeMlLearningStore();
var learning = new TestMlLearningStore();
learning.SeedOutbox("mle_1", "текст", "spam", 1.0);
IMlClient client = CreateClient(port, learning: learning);
@@ -246,7 +246,7 @@ public sealed class GrpcMlClientTests
Assert.False(reset.Ok);
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) =>
{
var learning = new FakeMlLearningStore();
var learning = new TestMlLearningStore();
IMlClient client = CreateClient(port, learning: learning);
await client.PushAsync(" нужен python ", "b_junior", 1.0, CancellationToken.None);
@@ -310,17 +310,18 @@ public sealed class GrpcMlClientTests
private static GrpcMlClient CreateClient(
int port,
TestSettingsStore? settings = null,
FakeMlLearningStore? learning = null,
TestMlLearningStore? learning = null,
MlStatusCache? cache = null)
{
settings ??= new TestSettingsStore();
learning ??= new TestMlLearningStore();
ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue));
var options = new MlServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" };
return new GrpcMlClient(
tenantContext,
settings.Store,
learning ?? new FakeMlLearningStore(),
learning.Store,
new MlGrpcConnection(options),
cache ?? new MlStatusCache(),
new TokenUsageRecorder(
@@ -147,7 +147,7 @@ public sealed class IntegrationsDiTests
services.AddScoped<ISettingsStore>(_ => new TestSettingsStore().Store);
services.AddScoped<ISecretCipher>(_ => TestCiphers.New());
services.AddScoped<ICardStore>(_ => new FakeKanjStore());
services.AddScoped<IMlLearningStore>(_ => new FakeMlLearningStore());
services.AddScoped<IMlLearningStore>(_ => new TestMlLearningStore().Store);
services.AddScoped<ITenantLimitStore>(_ => new TestTenantLimitStore().Store);
services.AddScoped<TokenUsageEventService>(_ => new TokenUsageEventService(new TestTokenUsageEventStore().Store));
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: Хранилище настроек тенанта.
// learning: Хранилище обучения (по умолчанию пустое: счётчики 0).
// Возвращает: Экземпляр LocalMlClient.
private static LocalMlClient CreateClient(TestSettingsStore store, FakeMlLearningStore? learning = null)
=> new(store.Store, learning ?? new FakeMlLearningStore());
private static LocalMlClient CreateClient(TestSettingsStore store, TestMlLearningStore? learning = null)
{
learning ??= new TestMlLearningStore();
return new(store.Store, learning.Store);
}
[Fact]
@@ -126,7 +129,7 @@ public sealed class LocalMlClientTests
public async Task StatusAsync_ReportsLearningAndOutboxFromTables()
{
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);
await client.PushAsync("Разработчик Python, готов к проекту", "b_alpha", 1.0, CancellationToken.None);
await client.PushAsync("Спам: купите курсы", "spam", -1.0, CancellationToken.None);
@@ -162,7 +165,7 @@ public sealed class LocalMlClientTests
[Fact]
public async Task PushAsync_WritesOutboxRow_WithMleIdAndTrimmedFields()
{
var learning = new FakeMlLearningStore();
var learning = new TestMlLearningStore();
LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
await client.PushAsync(" Python-разработчик, готов к работе ", " b_alpha ", 1.0, CancellationToken.None);
@@ -182,7 +185,7 @@ public sealed class LocalMlClientTests
[InlineData("текст", " ")] // label из пробелов — пуста после trim
public async Task PushAsync_EmptyTextOrLabelAfterTrim_IsNoOp(string text, string label)
{
var learning = new FakeMlLearningStore();
var learning = new TestMlLearningStore();
LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
await client.PushAsync(text, label, 1.0, CancellationToken.None);
@@ -193,7 +196,7 @@ public sealed class LocalMlClientTests
[Fact]
public async Task PushAsync_TextOver6000_IsTruncatedToMaxLength()
{
var learning = new FakeMlLearningStore();
var learning = new TestMlLearningStore();
LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
string longText = new string('а', MaxLearningTextLength + 1000);
@@ -207,7 +210,7 @@ public sealed class LocalMlClientTests
[Fact]
public async Task PushAsync_TruncationDoesNotSplitSurrogatePair()
{
var learning = new FakeMlLearningStore();
var learning = new TestMlLearningStore();
LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
// Длина 6001 в UTF-16: 5999 'x' + «🙂» (суррогатная пара) — срез .NET на 6000 попадает в пару.
string text = new string('x', MaxLearningTextLength - 1) + "🙂";
@@ -223,7 +226,7 @@ public sealed class LocalMlClientTests
[Fact]
public async Task PushAsync_NegativeDelta_IsStoredAsIs()
{
var learning = new FakeMlLearningStore();
var learning = new TestMlLearningStore();
LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
await client.PushAsync("Это не спам — вернул из корзины", "spam", -1.0, CancellationToken.None);
@@ -262,7 +265,7 @@ public sealed class LocalMlClientTests
{
var store = new TestSettingsStore();
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);
await client.PushAsync("текст 1", "b_alpha", 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));
}
}