Files
Deal/src/core/tests/Deal.Tests.Unit/Contracts/GrpcMlClientTests.cs
T
stepan a3b193e618 Перевести FakeMlLearningStore на NSubstitute
Подставка IMlLearningStore переведена на NSubstitute (TestMlLearningStore.Store); динамический CountOutboxAsync; обновлены 5 потребителей.
2026-09-13 02:49:08 +03:00

335 lines
15 KiB
C#

using Deal.Contracts.Integrations.Models;
using Deal.Grpc.Ml;
using Deal.Infrastructure.Data;
using Deal.Infrastructure.Integrations.Abstractions;
using Deal.Infrastructure.Integrations.Models;
using Deal.Infrastructure.Integrations.Options;
using Deal.Infrastructure.Integrations.Services;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants.Models;
using Deal.Tests.Unit.Grpc;
using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Modules.Tenants;
using Deal.Tests.Unit.Support;
using Microsoft.Extensions.Logging.Abstractions;
using Deal.Contracts.Integrations.Abstractions;
using Deal.SharedKernel.Tenants.Abstractions;
namespace Deal.Tests.Unit.Contracts;
/// <summary>
/// Тесты gRPC-адаптера IMlClient/IMlTrainClient к ml-service.
/// </summary>
[Collection("MlGrpcTests")]
public sealed class GrpcMlClientTests
{
// Идентификатор тенанта сценариев (формат N — ключ пула модели ml-service).
private static readonly Guid Tenant = Guid.NewGuid();
// Id тенанта строкой (формат N) — ожидаемое значение metadata tenant-id.
private static readonly string TenantIdValue = Tenant.ToString("N");
// ─── PredictAsync: маппинг и фолбэк ─────────────────────────────────────
[Fact]
public async Task PredictAsync_MapsFullReply_ToContractDtoAndSendsMetadata()
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
service.PredictReply = new PredictReply
{
Take = true,
Label = "b_junior",
Scores = { { "b_junior", 0.95 }, { "b_senior", 0.3 } },
Hits = 3,
Ready = true,
Margin = 0.9,
Terms = { "python", "middle" },
Type = new TypeDecision { Take = true, Label = "hire", Value = "t:hire", Margin = 0.5 },
};
IMlClient client = CreateClient(port);
MlPredictResultDto result = await client.PredictAsync("нужен middle python разработчик", CancellationToken.None);
Assert.True(result.Take);
Assert.Equal("b_junior", result.Label);
Assert.Equal(0.95, result.Scores["b_junior"]);
Assert.Equal(0.3, result.Scores["b_senior"]);
Assert.Equal(3, result.Hits);
Assert.True(result.Ready);
Assert.Equal(0.9, result.Margin);
Assert.Equal(new[] { "python", "middle" }, result.Terms);
Assert.NotNull(result.Type);
Assert.True(result.Type.Take);
Assert.Equal("hire", result.Type.Label);
Assert.Equal("t:hire", result.Type.Value);
Assert.Equal(0.5, result.Type.Margin);
Assert.Equal(TenantIdValue, Assert.Single(service.RequestTenantIds));
Assert.Equal(MlGrpcTestHost.DefaultToken, Assert.Single(service.RequestTokens));
});
}
[Fact]
public async Task PredictAsync_ServiceUnavailable_ReturnsNotReadyPrediction()
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
service.PredictUnavailable = true;
IMlClient client = CreateClient(port);
MlPredictResultDto result = await client.PredictAsync("текст", CancellationToken.None);
Assert.False(result.Take);
Assert.Null(result.Label);
Assert.Empty(result.Scores);
Assert.Equal(0, result.Hits);
Assert.False(result.Ready);
Assert.Null(result.Margin);
Assert.Empty(result.Terms);
Assert.Null(result.Type);
Assert.Single(service.RequestTenantIds);
});
}
// ─── StatusAsync: статус сервиса + кэш 15 с + reachable ─────────────────
[Fact]
public async Task StatusAsync_FetchesServiceStatusAndMergesLocalStats()
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
service.StatusReply = new StatusReply
{
Ready = true,
Classes = { { "b_junior", 12.0 }, { "spam", 5.0 } },
Learned = 17,
Eval = new ModelEval { Count = 5, Correct = 4, Accuracy = 0.8 },
};
var settings = new TestSettingsStore();
settings.Preload(SettingsKeys.MlDecisions, "7");
settings.Preload(SettingsKeys.AiDecisions, "3");
var learning = new TestMlLearningStore { LearningCount = 9 };
IMlClient client = CreateClient(port, settings, learning);
MlStatusResponseDto status = await client.StatusAsync(CancellationToken.None);
Assert.True(status.Enabled); // mlEnabled не задан — дефолт true
Assert.True(status.Reachable);
Assert.True(status.Service.Ready);
Assert.Equal(17, status.Service.Learned);
Assert.Equal(0.8, status.Service.Eval.Accuracy);
Assert.Equal(7, status.Stats.Ml);
Assert.Equal(3, status.Stats.Ai);
Assert.Equal(9, status.Stats.Learning);
Assert.Equal(0, status.Stats.Outbox);
Assert.True(status.Stats.Ready);
Assert.Equal(17, status.Stats.Learned);
Assert.True(status.Stats.Reachable);
Assert.Equal(TenantIdValue, Assert.Single(service.RequestTenantIds));
});
}
[Fact]
public async Task StatusAsync_ServiceDown_ServesCachedDataWithReachableFalse_ThenRecoversAfterTtl()
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
DateTimeOffset now = DateTimeOffset.UtcNow;
var cache = new MlStatusCache(() => now);
IMlClient client = CreateClient(port, cache: cache);
service.StatusUnavailable = true;
MlStatusResponseDto down = await client.StatusAsync(CancellationToken.None);
Assert.False(down.Reachable);
Assert.False(down.Service.Ready);
Assert.False(down.Stats.Reachable);
Assert.Equal(1, service.StatusCalls);
// «Поднялся»: после TTL 15 с следующий StatusAsync обновляет кэш (ready=true, reachable=true).
service.StatusUnavailable = false;
service.StatusReply = new StatusReply
{
Ready = true,
Classes = { { "b_x", 1.0 } },
Learned = 3,
Eval = new ModelEval { Count = 2, Correct = 2, Accuracy = 1.0 },
};
now = now.AddSeconds(MlStatusCache.CacheTtlSeconds + 1);
MlStatusResponseDto up = await client.StatusAsync(CancellationToken.None);
Assert.True(up.Reachable);
Assert.True(up.Service.Ready);
Assert.Equal(3, up.Service.Learned);
Assert.Equal(2, service.StatusCalls);
});
}
[Fact]
public async Task StatusAsync_CachesWithinTtl_SingleFetch()
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
DateTimeOffset now = DateTimeOffset.UtcNow;
IMlClient client = CreateClient(port, cache: new MlStatusCache(() => now));
_ = await client.StatusAsync(CancellationToken.None);
now = now.AddSeconds(5); // в пределах TTL 15 с — повторный вызов не ходит в сервис
_ = await client.StatusAsync(CancellationToken.None);
Assert.Equal(1, service.StatusCalls);
now = now.AddSeconds(MlStatusCache.CacheTtlSeconds + 1); // TTL истёк — новый fetch
_ = await client.StatusAsync(CancellationToken.None);
Assert.Equal(2, service.StatusCalls);
});
}
// ─── ResetAsync: gRPC Reset + очистка outbox только при успехе ──────────
[Fact]
public async Task ResetAsync_ServiceOk_ClearsOutboxAndInvalidatesStatusCache()
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
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);
// Прогреть кэш статуса (до сброса — 1 вызов Status), затем сброс.
_ = await client.StatusAsync(CancellationToken.None);
MlResetResultDto reset = await client.ResetAsync(CancellationToken.None);
Assert.True(reset.Ok);
Assert.Null(reset.Error);
Assert.Equal(0, await learning.Store.CountOutboxAsync(CancellationToken.None)); // очередь очищена
Assert.Equal(1, service.ResetCalls);
_ = await client.StatusAsync(CancellationToken.None);
Assert.Equal(2, service.StatusCalls);
});
}
[Fact]
public async Task ResetAsync_ServiceSoftError_ReturnsOkFalseAndKeepsOutbox()
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
service.ResetReply = new ResetReply { Ok = false, Error = "не удалось пересоздать файл модели" };
var learning = new TestMlLearningStore();
learning.SeedOutbox("mle_1", "текст", "b_a", 1.0);
IMlClient client = CreateClient(port, learning: learning);
MlResetResultDto reset = await client.ResetAsync(CancellationToken.None);
Assert.False(reset.Ok);
Assert.Equal("не удалось пересоздать файл модели", reset.Error);
Assert.Equal(1, await learning.Store.CountOutboxAsync(CancellationToken.None));
});
}
[Fact]
public async Task ResetAsync_ServiceUnavailable_ReturnsOkFalseAndKeepsOutbox()
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
service.ResetUnavailable = true;
var learning = new TestMlLearningStore();
learning.SeedOutbox("mle_1", "текст", "spam", 1.0);
IMlClient client = CreateClient(port, learning: learning);
MlResetResultDto reset = await client.ResetAsync(CancellationToken.None);
Assert.False(reset.Ok);
Assert.Equal("ML-сервис недоступен", reset.Error);
Assert.Equal(1, await learning.Store.CountOutboxAsync(CancellationToken.None));
});
}
[Fact]
public async Task PushAsync_WritesOutboxRow()
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
var learning = new TestMlLearningStore();
IMlClient client = CreateClient(port, learning: learning);
await client.PushAsync(" нужен python ", "b_junior", 1.0, CancellationToken.None);
var row = Assert.Single(learning.AddedRows);
Assert.StartsWith("mle_", row.Id, StringComparison.Ordinal);
Assert.Equal("нужен python", row.Text); // trim
Assert.Equal("b_junior", row.Label);
Assert.Equal(1.0, row.Delta);
Assert.Empty(service.RequestTenantIds); // push сервис не зовёт — только локальная запись
});
}
// ─── TrainBatchAsync (IMlTrainClient): батч для флашера ─────────────────
[Fact]
public async Task TrainBatchAsync_SendsItemsAndReturnsLearned()
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
IMlClient client = CreateClient(port);
var items = new[]
{
new MlOutboxEntryDto("mle_1", "текст 1", "b_a", 1.0),
new MlOutboxEntryDto("mle_2", "текст 2", "spam", -1.0),
};
int learned = await ((IMlTrainClient)client).TrainBatchAsync(items, CancellationToken.None);
Assert.Equal(2, learned);
TrainBatchRequest batch = Assert.Single(service.TrainBatches);
Assert.Equal(2, batch.Items.Count);
Assert.Equal("текст 1", batch.Items[0].Text);
Assert.Equal("b_a", batch.Items[0].Label);
Assert.Equal(1.0, batch.Items[0].Delta);
Assert.Equal("текст 2", batch.Items[1].Text);
Assert.Equal("spam", batch.Items[1].Label);
Assert.Equal(-1.0, batch.Items[1].Delta);
Assert.Equal(TenantIdValue, Assert.Single(service.RequestTenantIds));
});
}
// ─── Хелперы ────────────────────────────────────────────────────────────
// Создаёт GrpcMlClient к хосту-фейку на эфемерном порту с пустыми фейками статистики.
// port: Порт хоста-фейка ml-service.
// settings: KV-хранилище тенанта (пустое — дефолты).
// learning: Хранилище обучения (пустое).
// cache: Кэш статуса (по умолчанию реальный — UtcNow).
// Возвращает: Экземпляр GrpcMlClient в tenant-контексте теста.
private static GrpcMlClient CreateClient(
int port,
TestSettingsStore? settings = 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.Store,
new MlGrpcConnection(options),
cache ?? new MlStatusCache(),
new TokenUsageRecorder(
settings.Store,
new TestTenantLimitStore().Store,
tenantContext,
new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger<GrpcMlClient>.Instance);
}
}