Перевести FakeMlLearningStore на NSubstitute
Подставка IMlLearningStore переведена на NSubstitute (TestMlLearningStore.Store); динамический CountOutboxAsync; обновлены 5 потребителей.
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user