Хелпер Support/TestAiClassifier: вся поверхность фейка (FilterResult/ClassifyResult/ Throws-флаги/счётчики) сохранена, сервисы получают NSubstitute-подставку (.Classifier), поведение читает состояние на момент вызова, счётчики через When/Do. Потребители (5 файлов), фейк удалён, тесты 1340.
This commit is contained in:
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user