Перевести FakeAiTools на NSubstitute
ci / build-test (push) Canceled after 0s

Хелпер Support/TestAiTools: по умолчанию оба метода бросают NotSupportedException
(семантика прежнего фейка — неожиданный вызов роняет тест), сценарии через
ReturnsFit/ThrowsOnEvaluate, счётчик EvaluateFitCalls -> Received/DidNotReceive.
Потребители (4 файла) перетипизированы на IAiTools, фейк удалён, тесты 1340.
This commit is contained in:
Rustam Khalimov
2026-09-12 20:23:41 +03:00
parent e3ade19353
commit 28b9777fa0
6 changed files with 76 additions and 65 deletions
@@ -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);
}