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

Хелпер Support/TestAiClassifier: вся поверхность фейка (FilterResult/ClassifyResult/
Throws-флаги/счётчики) сохранена, сервисы получают NSubstitute-подставку
(.Classifier), поведение читает состояние на момент вызова, счётчики через
When/Do. Потребители (5 файлов), фейк удалён, тесты 1340.
This commit is contained in:
Rustam Khalimov
2026-09-12 20:31:15 +03:00
parent 76a705a1d8
commit b8c3f3e9aa
7 changed files with 94 additions and 80 deletions
@@ -6,6 +6,7 @@ using Deal.Modules.Tenants.Application.Models;
using Deal.SharedKernel.Tenants.Models;
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;
@@ -150,7 +151,7 @@ public sealed class BudgetedAiClassifierTests
// Settings: KV-настройки тенанта (маркеры LocalFieldsParser).
private sealed record Context(
IAiClassifier Decorator,
FakeAiClassifier Paid,
TestAiClassifier Paid,
FakeSettingsStore Settings);
// Собирает контекст: платный фейк + реальный Local-классификатор + фейк лимитов и tenant-контекст
@@ -164,9 +165,9 @@ public sealed class BudgetedAiClassifierTests
configure?.Invoke(limits);
ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue));
var paid = new FakeAiClassifier();
var paid = new TestAiClassifier();
IAiClassifier decorator = new BudgetedAiClassifier(
paid,
paid.Classifier,
new LocalAiClassifier(new LocalFieldsParser(settings)),
limits,
tenantContext,
@@ -1,66 +0,0 @@
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
namespace Deal.Tests.Unit.Contracts;
/// <summary>
/// In-memory реализация <see cref="IAiClassifier"/> для unit-тестов воркера pump.
/// </summary>
public sealed class FakeAiClassifier : IAiClassifier
{
/// <summary>
/// Ответ FilterAsync по умолчанию — как LocalAiClassifier
/// </summary>
public AiFilterResultDto FilterResult { get; set; } = new(Pass: true, Reason: null, Skipped: true);
/// <summary>
/// Ответ ClassifyAsync; null — ClassifyAsync бросает NotSupportedException
/// </summary>
public AiParsedCardDto? ClassifyResult { get; set; }
/// <summary>
/// Сбой FilterAsync.
/// </summary>
public bool FilterThrows { get; set; }
/// <summary>
/// Сбой ClassifyAsync.
/// </summary>
public bool ClassifyThrows { get; set; }
/// <summary>
/// Сколько раз вызван FilterAsync
/// </summary>
public int FilterCalls { get; private set; }
/// <summary>
/// Сколько раз вызван ClassifyAsync
/// </summary>
public int ClassifyCalls { get; private set; }
/// <inheritdoc />
public Task<AiFilterResultDto> FilterAsync(string text, CancellationToken ct)
{
FilterCalls++;
if (FilterThrows)
{
throw new InvalidOperationException("Тестовый сбой ИИ-фильтра (FilterThrows).");
}
return Task.FromResult(FilterResult);
}
/// <inheritdoc />
public Task<AiParsedCardDto> ClassifyAsync(string text, CancellationToken ct)
{
ClassifyCalls++;
if (ClassifyThrows)
{
throw new InvalidOperationException("Тестовый сбой классификатора (ClassifyThrows).");
}
return ClassifyResult is null
? throw new NotSupportedException()
: Task.FromResult(ClassifyResult);
}
}
@@ -31,7 +31,7 @@ public sealed class AdminTickOrchestratorTests
FakePipelineStore PipelineStore,
FakeKanjStore KanjStore,
FakeSettingsStore Settings,
FakeAiClassifier AiClassifier,
TestAiClassifier AiClassifier,
PipelinePumpGate PumpGate,
SseSubscription Subscription);
@@ -205,7 +205,7 @@ public sealed class AdminTickOrchestratorTests
FakePipelineStore store = withThrowingQueueRead ? new ThrowingQueueReadPipelineStore() : new FakePipelineStore();
var kanjStore = new FakeKanjStore();
var mlClient = new FakeMlClient();
var aiClassifier = new FakeAiClassifier();
var aiClassifier = new TestAiClassifier();
var rules = new IncomingRules(settings);
var fieldsParser = new LocalFieldsParser(settings);
var ingest = new PipelineIngestService(store);
@@ -213,7 +213,7 @@ public sealed class AdminTickOrchestratorTests
var composer = new CardComposer(kanjStore, settings);
var writer = new PipelineCardWriter(kanjStore, store, composer);
var worker = new PipelineWorkerService(
store, settings, rules, kanjStore, mlClient, aiClassifier, processing, writer, fieldsParser);
store, settings, rules, kanjStore, mlClient, aiClassifier.Classifier, processing, writer, fieldsParser);
var broker = new SseBroker();
var tickService = new StorageTickService(kanjStore, settings);
@@ -24,7 +24,7 @@ public sealed class CardReclassifierTests
FakeKanjStore Store,
FakeSettingsStore Settings,
FakeMlClient MlClient,
FakeAiClassifier AiClassifier,
TestAiClassifier AiClassifier,
ReclassifyGate Gate);
// Собирает контекст: фейки хранилищ/портов, реальные композитор/парсер/сервис карточек.
@@ -34,13 +34,13 @@ public sealed class CardReclassifierTests
var settings = new FakeSettingsStore();
var store = new FakeKanjStore();
var mlClient = new FakeMlClient();
var aiClassifier = new FakeAiClassifier();
var aiClassifier = new TestAiClassifier();
var fieldsParser = new LocalFieldsParser(settings);
var composer = new CardComposer(store, settings);
var cardsService = new CardsService(store, settings, mlClient, new FakeFileStorage());
var gate = new ReclassifyGate();
var reclassifier = new CardReclassifier(
store, settings, aiClassifier, fieldsParser, composer, cardsService, mlClient, gate);
store, settings, aiClassifier.Classifier, fieldsParser, composer, cardsService, mlClient, gate);
return new Context(reclassifier, store, settings, mlClient, aiClassifier, gate);
}
@@ -157,7 +157,7 @@ public sealed class PipelineWorkerSchedulerTests
var settingsA = new FakeSettingsStore();
var settingsB = new FakeSettingsStore();
var pumpGate = new PipelinePumpGate();
var aiClassifier = new FakeAiClassifier
var aiClassifier = new TestAiClassifier
{
ClassifyResult = Parsed("Python-разработчик в команду", isVacancy: true),
};
@@ -168,7 +168,7 @@ public sealed class PipelineWorkerSchedulerTests
services.AddSingleton<ITenantRepository>(tenants);
services.AddSingleton(pumpGate);
services.AddSingleton<IMlClient>(new FakeMlClient());
services.AddSingleton<IAiClassifier>(aiClassifier);
services.AddSingleton<IAiClassifier>(aiClassifier.Classifier);
// Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant
// (эталон StorageTickSchedulerTests/ConnectionStringProvider.ForTenant).
services.AddScoped<ICardStore>(provider => TenantOf(provider) == TenantA ? kanjA : kanjB);
@@ -28,7 +28,7 @@ public sealed class PipelineWorkerServiceTests
FakeKanjStore KanjStore,
FakeSettingsStore Settings,
FakeMlClient MlClient,
FakeAiClassifier AiClassifier);
TestAiClassifier AiClassifier);
// Собирает контекст: дефолты настроек/досок, ML «не готов» (как LocalMlClient), ИИ-фильтр пропускает.
// pipelineStore: Хранилище очереди (по умолчанию — обычный фейк; сценарий гонки claim'а — RacingClaimPipelineStore).
@@ -39,14 +39,14 @@ public sealed class PipelineWorkerServiceTests
var store = pipelineStore ?? new FakePipelineStore();
var kanjStore = new FakeKanjStore();
var mlClient = new FakeMlClient { Predict = NotReadyPrediction() };
var aiClassifier = new FakeAiClassifier();
var aiClassifier = new TestAiClassifier();
var rules = new IncomingRules(settings);
var fieldsParser = new LocalFieldsParser(settings);
var processing = new PipelineProcessingService(store, mlClient, new PipelineIngestService(store));
var composer = new CardComposer(kanjStore, settings);
var writer = new PipelineCardWriter(kanjStore, store, composer);
var worker = new PipelineWorkerService(
store, settings, rules, kanjStore, mlClient, aiClassifier, processing, writer, fieldsParser);
store, settings, rules, kanjStore, mlClient, aiClassifier.Classifier, processing, writer, fieldsParser);
return new Context(worker, store, kanjStore, settings, mlClient, aiClassifier);
}
@@ -0,0 +1,79 @@
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using NSubstitute;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Подставка <see cref="IAiClassifier"/> со сценарием состояния: сервисы получают NSubstitute-подставку
/// (<see cref="Classifier"/>), тесты управляют поведением через свойства (как раньше — через фейк).
/// </summary>
public sealed class TestAiClassifier
{
/// <summary>
/// Ответ FilterAsync по умолчанию — как LocalAiClassifier
/// </summary>
public AiFilterResultDto FilterResult { get; set; } = new(Pass: true, Reason: null, Skipped: true);
/// <summary>
/// Ответ ClassifyAsync; null — ClassifyAsync бросает NotSupportedException
/// </summary>
public AiParsedCardDto? ClassifyResult { get; set; }
/// <summary>
/// Сбой FilterAsync.
/// </summary>
public bool FilterThrows { get; set; }
/// <summary>
/// Сбой ClassifyAsync.
/// </summary>
public bool ClassifyThrows { get; set; }
/// <summary>
/// Сколько раз вызван FilterAsync
/// </summary>
public int FilterCalls { get; private set; }
/// <summary>
/// Сколько раз вызван ClassifyAsync
/// </summary>
public int ClassifyCalls { get; private set; }
/// <summary>
/// Подставка порта классификатора (создаётся в конструкторе).
/// </summary>
public IAiClassifier Classifier { get; }
/// <summary>
/// Создаёт подставку с поведением по умолчанию.
/// </summary>
public TestAiClassifier()
{
Classifier = Substitute.For<IAiClassifier>();
Classifier.When(c => c.FilterAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()))
.Do(_ => FilterCalls++);
Classifier.FilterAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(_ =>
{
if (FilterThrows)
{
throw new InvalidOperationException("Тестовый сбой ИИ-фильтра (FilterThrows).");
}
return FilterResult;
});
Classifier.When(c => c.ClassifyAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()))
.Do(_ => ClassifyCalls++);
Classifier.ClassifyAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns<AiParsedCardDto>(_ =>
{
if (ClassifyThrows)
{
throw new InvalidOperationException("Тестовый сбой классификатора (ClassifyThrows).");
}
return ClassifyResult ?? throw new NotSupportedException();
});
}
}