Хелпер 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.SharedKernel.Tenants.Models;
|
||||||
using Deal.Tests.Unit.Modules.Settings;
|
using Deal.Tests.Unit.Modules.Settings;
|
||||||
using Deal.Tests.Unit.Modules.Tenants;
|
using Deal.Tests.Unit.Modules.Tenants;
|
||||||
|
using Deal.Tests.Unit.Support;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Deal.Contracts.Integrations.Abstractions;
|
using Deal.Contracts.Integrations.Abstractions;
|
||||||
using Deal.SharedKernel.Tenants.Abstractions;
|
using Deal.SharedKernel.Tenants.Abstractions;
|
||||||
@@ -150,7 +151,7 @@ public sealed class BudgetedAiClassifierTests
|
|||||||
// Settings: KV-настройки тенанта (маркеры LocalFieldsParser).
|
// Settings: KV-настройки тенанта (маркеры LocalFieldsParser).
|
||||||
private sealed record Context(
|
private sealed record Context(
|
||||||
IAiClassifier Decorator,
|
IAiClassifier Decorator,
|
||||||
FakeAiClassifier Paid,
|
TestAiClassifier Paid,
|
||||||
FakeSettingsStore Settings);
|
FakeSettingsStore Settings);
|
||||||
|
|
||||||
// Собирает контекст: платный фейк + реальный Local-классификатор + фейк лимитов и tenant-контекст
|
// Собирает контекст: платный фейк + реальный Local-классификатор + фейк лимитов и tenant-контекст
|
||||||
@@ -164,9 +165,9 @@ public sealed class BudgetedAiClassifierTests
|
|||||||
configure?.Invoke(limits);
|
configure?.Invoke(limits);
|
||||||
ITenantContext tenantContext = new TenantContext();
|
ITenantContext tenantContext = new TenantContext();
|
||||||
tenantContext.SetTenant(new TenantId(TenantIdValue));
|
tenantContext.SetTenant(new TenantId(TenantIdValue));
|
||||||
var paid = new FakeAiClassifier();
|
var paid = new TestAiClassifier();
|
||||||
IAiClassifier decorator = new BudgetedAiClassifier(
|
IAiClassifier decorator = new BudgetedAiClassifier(
|
||||||
paid,
|
paid.Classifier,
|
||||||
new LocalAiClassifier(new LocalFieldsParser(settings)),
|
new LocalAiClassifier(new LocalFieldsParser(settings)),
|
||||||
limits,
|
limits,
|
||||||
tenantContext,
|
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,
|
FakePipelineStore PipelineStore,
|
||||||
FakeKanjStore KanjStore,
|
FakeKanjStore KanjStore,
|
||||||
FakeSettingsStore Settings,
|
FakeSettingsStore Settings,
|
||||||
FakeAiClassifier AiClassifier,
|
TestAiClassifier AiClassifier,
|
||||||
PipelinePumpGate PumpGate,
|
PipelinePumpGate PumpGate,
|
||||||
SseSubscription Subscription);
|
SseSubscription Subscription);
|
||||||
|
|
||||||
@@ -205,7 +205,7 @@ public sealed class AdminTickOrchestratorTests
|
|||||||
FakePipelineStore store = withThrowingQueueRead ? new ThrowingQueueReadPipelineStore() : new FakePipelineStore();
|
FakePipelineStore store = withThrowingQueueRead ? new ThrowingQueueReadPipelineStore() : new FakePipelineStore();
|
||||||
var kanjStore = new FakeKanjStore();
|
var kanjStore = new FakeKanjStore();
|
||||||
var mlClient = new FakeMlClient();
|
var mlClient = new FakeMlClient();
|
||||||
var aiClassifier = new FakeAiClassifier();
|
var aiClassifier = new TestAiClassifier();
|
||||||
var rules = new IncomingRules(settings);
|
var rules = new IncomingRules(settings);
|
||||||
var fieldsParser = new LocalFieldsParser(settings);
|
var fieldsParser = new LocalFieldsParser(settings);
|
||||||
var ingest = new PipelineIngestService(store);
|
var ingest = new PipelineIngestService(store);
|
||||||
@@ -213,7 +213,7 @@ public sealed class AdminTickOrchestratorTests
|
|||||||
var composer = new CardComposer(kanjStore, settings);
|
var composer = new CardComposer(kanjStore, settings);
|
||||||
var writer = new PipelineCardWriter(kanjStore, store, composer);
|
var writer = new PipelineCardWriter(kanjStore, store, composer);
|
||||||
var worker = new PipelineWorkerService(
|
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 broker = new SseBroker();
|
||||||
var tickService = new StorageTickService(kanjStore, settings);
|
var tickService = new StorageTickService(kanjStore, settings);
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ public sealed class CardReclassifierTests
|
|||||||
FakeKanjStore Store,
|
FakeKanjStore Store,
|
||||||
FakeSettingsStore Settings,
|
FakeSettingsStore Settings,
|
||||||
FakeMlClient MlClient,
|
FakeMlClient MlClient,
|
||||||
FakeAiClassifier AiClassifier,
|
TestAiClassifier AiClassifier,
|
||||||
ReclassifyGate Gate);
|
ReclassifyGate Gate);
|
||||||
|
|
||||||
// Собирает контекст: фейки хранилищ/портов, реальные композитор/парсер/сервис карточек.
|
// Собирает контекст: фейки хранилищ/портов, реальные композитор/парсер/сервис карточек.
|
||||||
@@ -34,13 +34,13 @@ public sealed class CardReclassifierTests
|
|||||||
var settings = new FakeSettingsStore();
|
var settings = new FakeSettingsStore();
|
||||||
var store = new FakeKanjStore();
|
var store = new FakeKanjStore();
|
||||||
var mlClient = new FakeMlClient();
|
var mlClient = new FakeMlClient();
|
||||||
var aiClassifier = new FakeAiClassifier();
|
var aiClassifier = new TestAiClassifier();
|
||||||
var fieldsParser = new LocalFieldsParser(settings);
|
var fieldsParser = new LocalFieldsParser(settings);
|
||||||
var composer = new CardComposer(store, settings);
|
var composer = new CardComposer(store, settings);
|
||||||
var cardsService = new CardsService(store, settings, mlClient, new FakeFileStorage());
|
var cardsService = new CardsService(store, settings, mlClient, new FakeFileStorage());
|
||||||
var gate = new ReclassifyGate();
|
var gate = new ReclassifyGate();
|
||||||
var reclassifier = new CardReclassifier(
|
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);
|
return new Context(reclassifier, store, settings, mlClient, aiClassifier, gate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ public sealed class PipelineWorkerSchedulerTests
|
|||||||
var settingsA = new FakeSettingsStore();
|
var settingsA = new FakeSettingsStore();
|
||||||
var settingsB = new FakeSettingsStore();
|
var settingsB = new FakeSettingsStore();
|
||||||
var pumpGate = new PipelinePumpGate();
|
var pumpGate = new PipelinePumpGate();
|
||||||
var aiClassifier = new FakeAiClassifier
|
var aiClassifier = new TestAiClassifier
|
||||||
{
|
{
|
||||||
ClassifyResult = Parsed("Python-разработчик в команду", isVacancy: true),
|
ClassifyResult = Parsed("Python-разработчик в команду", isVacancy: true),
|
||||||
};
|
};
|
||||||
@@ -168,7 +168,7 @@ public sealed class PipelineWorkerSchedulerTests
|
|||||||
services.AddSingleton<ITenantRepository>(tenants);
|
services.AddSingleton<ITenantRepository>(tenants);
|
||||||
services.AddSingleton(pumpGate);
|
services.AddSingleton(pumpGate);
|
||||||
services.AddSingleton<IMlClient>(new FakeMlClient());
|
services.AddSingleton<IMlClient>(new FakeMlClient());
|
||||||
services.AddSingleton<IAiClassifier>(aiClassifier);
|
services.AddSingleton<IAiClassifier>(aiClassifier.Classifier);
|
||||||
// Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant
|
// Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant
|
||||||
// (эталон StorageTickSchedulerTests/ConnectionStringProvider.ForTenant).
|
// (эталон StorageTickSchedulerTests/ConnectionStringProvider.ForTenant).
|
||||||
services.AddScoped<ICardStore>(provider => TenantOf(provider) == TenantA ? kanjA : kanjB);
|
services.AddScoped<ICardStore>(provider => TenantOf(provider) == TenantA ? kanjA : kanjB);
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ public sealed class PipelineWorkerServiceTests
|
|||||||
FakeKanjStore KanjStore,
|
FakeKanjStore KanjStore,
|
||||||
FakeSettingsStore Settings,
|
FakeSettingsStore Settings,
|
||||||
FakeMlClient MlClient,
|
FakeMlClient MlClient,
|
||||||
FakeAiClassifier AiClassifier);
|
TestAiClassifier AiClassifier);
|
||||||
|
|
||||||
// Собирает контекст: дефолты настроек/досок, ML «не готов» (как LocalMlClient), ИИ-фильтр пропускает.
|
// Собирает контекст: дефолты настроек/досок, ML «не готов» (как LocalMlClient), ИИ-фильтр пропускает.
|
||||||
// pipelineStore: Хранилище очереди (по умолчанию — обычный фейк; сценарий гонки claim'а — RacingClaimPipelineStore).
|
// pipelineStore: Хранилище очереди (по умолчанию — обычный фейк; сценарий гонки claim'а — RacingClaimPipelineStore).
|
||||||
@@ -39,14 +39,14 @@ public sealed class PipelineWorkerServiceTests
|
|||||||
var store = pipelineStore ?? new FakePipelineStore();
|
var store = pipelineStore ?? new FakePipelineStore();
|
||||||
var kanjStore = new FakeKanjStore();
|
var kanjStore = new FakeKanjStore();
|
||||||
var mlClient = new FakeMlClient { Predict = NotReadyPrediction() };
|
var mlClient = new FakeMlClient { Predict = NotReadyPrediction() };
|
||||||
var aiClassifier = new FakeAiClassifier();
|
var aiClassifier = new TestAiClassifier();
|
||||||
var rules = new IncomingRules(settings);
|
var rules = new IncomingRules(settings);
|
||||||
var fieldsParser = new LocalFieldsParser(settings);
|
var fieldsParser = new LocalFieldsParser(settings);
|
||||||
var processing = new PipelineProcessingService(store, mlClient, new PipelineIngestService(store));
|
var processing = new PipelineProcessingService(store, mlClient, new PipelineIngestService(store));
|
||||||
var composer = new CardComposer(kanjStore, settings);
|
var composer = new CardComposer(kanjStore, settings);
|
||||||
var writer = new PipelineCardWriter(kanjStore, store, composer);
|
var writer = new PipelineCardWriter(kanjStore, store, composer);
|
||||||
var worker = new PipelineWorkerService(
|
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);
|
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