Подставка IMlLearningStore переведена на NSubstitute (TestMlLearningStore.Store); динамический CountOutboxAsync; обновлены 5 потребителей.
335 lines
15 KiB
C#
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);
|
|
}
|
|
}
|