Хелпер Support/TestAiTools: по умолчанию оба метода бросают NotSupportedException (семантика прежнего фейка — неожиданный вызов роняет тест), сценарии через ReturnsFit/ThrowsOnEvaluate, счётчик EvaluateFitCalls -> Received/DidNotReceive. Потребители (4 файла) перетипизированы на IAiTools, фейк удалён, тесты 1340.
This commit is contained in:
@@ -6,6 +6,7 @@ using Deal.Modules.Discovery.Application.Abstractions;
|
||||
using Deal.Modules.Discovery.Application.Models;
|
||||
using Deal.Modules.Discovery.Application.Registrars;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Tests.Unit.Support;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
@@ -104,7 +105,7 @@ public sealed class DiscoveryWorkerSchedulerTests
|
||||
services.AddSingleton<ITenantContext>(tenantContext);
|
||||
services.AddSingleton<ITenantRepository>(tenants);
|
||||
services.AddSingleton<IMlClient>(new FakeMlClient());
|
||||
services.AddSingleton<IAiTools>(new FakeAiTools());
|
||||
services.AddSingleton<IAiTools>(_ => TestAiTools.New());
|
||||
// Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant
|
||||
// (эталон PipelineWorkerSchedulerTests/ConnectionStringProvider.ForTenant).
|
||||
services.AddScoped<IDiscoveryStore>(provider => TenantOf(provider) == TenantA ? storeA : storeB);
|
||||
|
||||
@@ -5,6 +5,8 @@ using Deal.Infrastructure.Integrations.Services;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
using Deal.Tests.Unit.Modules.Tenants;
|
||||
using Deal.Tests.Unit.Support;
|
||||
using NSubstitute;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.SharedKernel.Tenants.Abstractions;
|
||||
@@ -31,12 +33,12 @@ public sealed class BudgetedAiToolsTests
|
||||
Context ctx = Create(
|
||||
limits => limits.Preload(
|
||||
TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, PeriodStart, usedTokens: 1000));
|
||||
ctx.Paid.Fit = new AiEvaluateFitResultDto(Fit: true, Reason: "подходит");
|
||||
ctx.Paid.ReturnsFit(new AiEvaluateFitResultDto(Fit: true, Reason: "подходит"));
|
||||
|
||||
AiUnavailableException exception = await Assert.ThrowsAsync<AiUnavailableException>(
|
||||
() => ctx.Decorator.EvaluateFitAsync("текст", "описание", new[] { "ключ" }, CancellationToken.None));
|
||||
|
||||
Assert.Equal(0, ctx.Paid.EvaluateFitCalls);
|
||||
await ctx.Paid.DidNotReceive().EvaluateFitAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<IReadOnlyCollection<string>>(), Arg.Any<CancellationToken>());
|
||||
Assert.Contains("исчерпан", exception.Message);
|
||||
}
|
||||
|
||||
@@ -49,7 +51,7 @@ public sealed class BudgetedAiToolsTests
|
||||
await Assert.ThrowsAsync<AiUnavailableException>(
|
||||
() => ctx.Decorator.EvaluateFitAsync("текст", "описание", Array.Empty<string>(), CancellationToken.None));
|
||||
|
||||
Assert.Equal(0, ctx.Paid.EvaluateFitCalls);
|
||||
await ctx.Paid.DidNotReceive().EvaluateFitAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<IReadOnlyCollection<string>>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -63,7 +65,7 @@ public sealed class BudgetedAiToolsTests
|
||||
AiUnavailableException exception = await Assert.ThrowsAsync<AiUnavailableException>(
|
||||
() => ctx.Decorator.EvaluateFitAsync("текст", "описание", Array.Empty<string>(), CancellationToken.None));
|
||||
|
||||
Assert.Equal(0, ctx.Paid.EvaluateFitCalls);
|
||||
await ctx.Paid.DidNotReceive().EvaluateFitAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<IReadOnlyCollection<string>>(), Arg.Any<CancellationToken>());
|
||||
Assert.Contains("приостановлен", exception.Message);
|
||||
}
|
||||
|
||||
@@ -72,13 +74,13 @@ public sealed class BudgetedAiToolsTests
|
||||
{
|
||||
// Строки лимита нет — ленивый дефолт (10 000 000/месяц, расход 0) → Allowed=true → платный вызов.
|
||||
Context ctx = Create();
|
||||
ctx.Paid.Fit = new AiEvaluateFitResultDto(Fit: false, Reason: "не подходит");
|
||||
ctx.Paid.ReturnsFit(new AiEvaluateFitResultDto(Fit: false, Reason: "не подходит"));
|
||||
|
||||
AiEvaluateFitResultDto result = await ctx.Decorator.EvaluateFitAsync(
|
||||
"текст", "описание", new[] { "ключ" }, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Fit);
|
||||
Assert.Equal(1, ctx.Paid.EvaluateFitCalls);
|
||||
await ctx.Paid.Received(1).EvaluateFitAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<IReadOnlyCollection<string>>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -116,7 +118,7 @@ public sealed class BudgetedAiToolsTests
|
||||
public async Task GenerateKeywords_LargeBudget_DelegatesToPaid()
|
||||
{
|
||||
// Строки лимита нет — ленивый дефолт → Allowed=true → декоратор делегирует платному исполнителю.
|
||||
// FakeAiTools.GenerateKeywordsAsync всегда бросает NotSupportedException (вызов не ждали) — само
|
||||
// Подставка бросает NotSupportedException на GenerateKeywordsAsync (вызов не ждали) — само
|
||||
// исключение здесь и есть признак делегирования (упало бы в тесте, если бы декоратор не позвал платный).
|
||||
Context ctx = Create();
|
||||
|
||||
@@ -131,7 +133,7 @@ public sealed class BudgetedAiToolsTests
|
||||
// Paid: Платный фейк-исполнитель (счётчик/ответы сценария).
|
||||
private sealed record Context(
|
||||
IAiTools Decorator,
|
||||
FakeAiTools Paid);
|
||||
IAiTools Paid);
|
||||
|
||||
// Собирает контекст: платный фейк + фейк лимитов и tenant-контекст (как регистрирует
|
||||
// AddDealIntegrations при UseLocal=false).
|
||||
@@ -143,7 +145,7 @@ public sealed class BudgetedAiToolsTests
|
||||
configure?.Invoke(limits);
|
||||
ITenantContext tenantContext = new TenantContext();
|
||||
tenantContext.SetTenant(new TenantId(TenantIdValue));
|
||||
var paid = new FakeAiTools();
|
||||
var paid = TestAiTools.New();
|
||||
IAiTools decorator = new BudgetedAiTools(
|
||||
paid,
|
||||
limits,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Discovery.Application.Models;
|
||||
using Deal.Modules.Discovery.Application.Services;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Tests.Unit.Modules.Settings;
|
||||
using Deal.Tests.Unit.Support;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Deal.Tests.Unit.Contracts;
|
||||
|
||||
@@ -43,8 +46,8 @@ public sealed class DiscoveryEvaluatorTests
|
||||
[Fact]
|
||||
public async Task EvaluateMessage_AiFits_ReturnsAiVerdict()
|
||||
{
|
||||
(DiscoveryEvaluator evaluator, _, FakeMlClient ml, FakeAiTools ai) = Create(aiEnabled: true, mlEnabled: false);
|
||||
ai.Fit = new AiEvaluateFitResultDto(Fit: true, "тема совпадает");
|
||||
(DiscoveryEvaluator evaluator, _, FakeMlClient ml, IAiTools ai) = Create(aiEnabled: true, mlEnabled: false);
|
||||
ai.ReturnsFit(new AiEvaluateFitResultDto(Fit: true, "тема совпадает"));
|
||||
|
||||
DiscoveryMessageFit fit = await evaluator.EvaluateMessageAsync(
|
||||
TaskDto(keywords: ["заказ"]), "Нужен заказ на разработку CRM для агентства недвижимости", CancellationToken.None);
|
||||
@@ -52,14 +55,14 @@ public sealed class DiscoveryEvaluatorTests
|
||||
Assert.True(fit.Fit);
|
||||
Assert.Equal("тема совпадает", fit.Reason);
|
||||
Assert.Equal("ai", fit.Source);
|
||||
Assert.Equal(1, ai.EvaluateFitCalls);
|
||||
await ai.Received(1).EvaluateFitAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<IReadOnlyCollection<string>>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateMessage_AiFailure_FallsBackToHeuristic()
|
||||
{
|
||||
(DiscoveryEvaluator evaluator, _, _, FakeAiTools ai) = Create(aiEnabled: true, mlEnabled: false);
|
||||
ai.Error = new InvalidOperationException("ai-service недоступен"); // как gRPC AiUnavailableException
|
||||
(DiscoveryEvaluator evaluator, _, _, IAiTools ai) = Create(aiEnabled: true, mlEnabled: false);
|
||||
ai.ThrowsOnEvaluate(new InvalidOperationException("ai-service недоступен")); // как gRPC AiUnavailableException
|
||||
DiscoveryTaskDto task = TaskDto(keywords: ["заказ"]);
|
||||
|
||||
DiscoveryMessageFit fit = await evaluator.EvaluateMessageAsync(
|
||||
@@ -165,13 +168,13 @@ public sealed class DiscoveryEvaluatorTests
|
||||
=> new(id, text, DateMs: 1_700_000_000_000 + id, topic, TopicTitle: null);
|
||||
|
||||
// Создаёт оценщик на фейках: флаги веток выставляются сценарием.
|
||||
private static (DiscoveryEvaluator Evaluator, FakeSettingsStore Settings, FakeMlClient Ml, FakeAiTools Ai) Create(bool aiEnabled, bool mlEnabled)
|
||||
private static (DiscoveryEvaluator Evaluator, FakeSettingsStore Settings, FakeMlClient Ml, IAiTools Ai) Create(bool aiEnabled, bool mlEnabled)
|
||||
{
|
||||
var settings = new FakeSettingsStore();
|
||||
settings.Preload(SettingsKeys.AiEnabled, aiEnabled ? "true" : "false");
|
||||
settings.Preload(SettingsKeys.MlEnabled, mlEnabled ? "true" : "false");
|
||||
var ml = new FakeMlClient();
|
||||
var ai = new FakeAiTools();
|
||||
var ai = TestAiTools.New();
|
||||
return (new DiscoveryEvaluator(settings, ml, ai), settings, ml, ai);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ using Deal.Tests.Unit.Modules.Discovery;
|
||||
using Deal.Modules.Discovery.Application.Services;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Tests.Unit.Modules.Settings;
|
||||
using Deal.Tests.Unit.Support;
|
||||
using NSubstitute;
|
||||
using Grpc.Core;
|
||||
|
||||
@@ -432,7 +433,7 @@ public sealed class DiscoveryWorkerServiceTests
|
||||
settings.Preload(SettingsKeys.MlEnabled, "false");
|
||||
settings.Preload(SettingsKeys.AiEnabled, "false");
|
||||
var ml = new FakeMlClient();
|
||||
var ai = new FakeAiTools();
|
||||
var ai = TestAiTools.New();
|
||||
var log = new DiscoveryLogService(store);
|
||||
var blacklist = new DiscoveryBlacklistService(store);
|
||||
var candidates = new DiscoveryCandidatesService(store, log, blacklist);
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
|
||||
namespace Deal.Tests.Unit.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Фейковая реализация <see cref="IAiTools"/> для тестов Discovery-воркера/оценки.
|
||||
/// </summary>
|
||||
public sealed class FakeAiTools : IAiTools
|
||||
{
|
||||
/// <summary>
|
||||
/// Ответ EvaluateFitAsync; null — вызов бросит <see cref="NotSupportedException"/>
|
||||
/// </summary>
|
||||
public AiEvaluateFitResultDto? Fit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Явный сбой EvaluateFitAsync
|
||||
/// </summary>
|
||||
public Exception? Error { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Сколько раз вызван EvaluateFitAsync
|
||||
/// </summary>
|
||||
public int EvaluateFitCalls { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<AiEvaluateFitResultDto> EvaluateFitAsync(
|
||||
string text,
|
||||
string description,
|
||||
IReadOnlyCollection<string> keywords,
|
||||
CancellationToken ct)
|
||||
{
|
||||
EvaluateFitCalls++;
|
||||
if (Error is not null)
|
||||
{
|
||||
throw Error;
|
||||
}
|
||||
|
||||
return Fit is null
|
||||
? throw new NotSupportedException()
|
||||
: Task.FromResult(Fit);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<AiGenerateKeywordsResultDto> GenerateKeywordsAsync(string description, CancellationToken ct)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Deal.Tests.Unit.Support;
|
||||
|
||||
/// <summary>
|
||||
/// Подставка <see cref="IAiTools"/> для сценариев Discovery/бюджетного гейта.
|
||||
/// </summary>
|
||||
public static class TestAiTools
|
||||
{
|
||||
/// <summary>
|
||||
/// Создаёт подставку: по умолчанию оба метода бросают <see cref="NotSupportedException"/> —
|
||||
/// тесты переопределяют только то, что ждут (как раньше делал фейк).
|
||||
/// </summary>
|
||||
public static IAiTools New() => Configure(Substitute.For<IAiTools>());
|
||||
|
||||
/// <summary>
|
||||
/// Настраивает «запрещённые по умолчанию» методы подставки.
|
||||
/// </summary>
|
||||
/// <param name="ai">Подставка инструментов ИИ.</param>
|
||||
public static IAiTools Configure(IAiTools ai)
|
||||
{
|
||||
ai.EvaluateFitAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<IReadOnlyCollection<string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns<AiEvaluateFitResultDto>(_ => throw new NotSupportedException());
|
||||
ai.GenerateKeywordsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns<AiGenerateKeywordsResultDto>(_ => throw new NotSupportedException());
|
||||
return ai;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Сценарий: <see cref="IAiTools.EvaluateFitAsync"/> возвращает заданный результат.
|
||||
/// </summary>
|
||||
/// <param name="ai">Подставка инструментов ИИ.</param>
|
||||
/// <param name="fit">Результат оценки соответствия.</param>
|
||||
public static void ReturnsFit(this IAiTools ai, AiEvaluateFitResultDto fit) =>
|
||||
ai.EvaluateFitAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<IReadOnlyCollection<string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(fit);
|
||||
|
||||
/// <summary>
|
||||
/// Сценарий: <see cref="IAiTools.EvaluateFitAsync"/> бросает заданное исключение.
|
||||
/// </summary>
|
||||
/// <param name="ai">Подставка инструментов ИИ.</param>
|
||||
/// <param name="error">Исключение для вызова.</param>
|
||||
public static void ThrowsOnEvaluate(this IAiTools ai, Exception error) =>
|
||||
ai.EvaluateFitAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<IReadOnlyCollection<string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns<AiEvaluateFitResultDto>(_ => throw error);
|
||||
}
|
||||
Reference in New Issue
Block a user