diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiClassifierTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiClassifierTests.cs index cb302e3..5956995 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiClassifierTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiClassifierTests.cs @@ -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, diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/FakeAiClassifier.cs b/src/core/tests/Deal.Tests.Unit/Contracts/FakeAiClassifier.cs deleted file mode 100644 index 504666f..0000000 --- a/src/core/tests/Deal.Tests.Unit/Contracts/FakeAiClassifier.cs +++ /dev/null @@ -1,66 +0,0 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; - -namespace Deal.Tests.Unit.Contracts; - -/// -/// In-memory реализация для unit-тестов воркера pump. -/// -public sealed class FakeAiClassifier : IAiClassifier -{ - /// - /// Ответ FilterAsync по умолчанию — как LocalAiClassifier - /// - public AiFilterResultDto FilterResult { get; set; } = new(Pass: true, Reason: null, Skipped: true); - - /// - /// Ответ ClassifyAsync; null — ClassifyAsync бросает NotSupportedException - /// - public AiParsedCardDto? ClassifyResult { get; set; } - - /// - /// Сбой FilterAsync. - /// - public bool FilterThrows { get; set; } - - /// - /// Сбой ClassifyAsync. - /// - public bool ClassifyThrows { get; set; } - - /// - /// Сколько раз вызван FilterAsync - /// - public int FilterCalls { get; private set; } - - /// - /// Сколько раз вызван ClassifyAsync - /// - public int ClassifyCalls { get; private set; } - - /// - public Task FilterAsync(string text, CancellationToken ct) - { - FilterCalls++; - if (FilterThrows) - { - throw new InvalidOperationException("Тестовый сбой ИИ-фильтра (FilterThrows)."); - } - - return Task.FromResult(FilterResult); - } - - /// - public Task ClassifyAsync(string text, CancellationToken ct) - { - ClassifyCalls++; - if (ClassifyThrows) - { - throw new InvalidOperationException("Тестовый сбой классификатора (ClassifyThrows)."); - } - - return ClassifyResult is null - ? throw new NotSupportedException() - : Task.FromResult(ClassifyResult); - } -} diff --git a/src/core/tests/Deal.Tests.Unit/Support/AdminTickOrchestratorTests.cs b/src/core/tests/Deal.Tests.Unit/Support/AdminTickOrchestratorTests.cs index 4cc10c0..05a0a6f 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/AdminTickOrchestratorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/AdminTickOrchestratorTests.cs @@ -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); diff --git a/src/core/tests/Deal.Tests.Unit/Support/CardReclassifierTests.cs b/src/core/tests/Deal.Tests.Unit/Support/CardReclassifierTests.cs index a3c408c..c767e19 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/CardReclassifierTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/CardReclassifierTests.cs @@ -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); } diff --git a/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerSchedulerTests.cs index acd1c14..4a0b1db 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerSchedulerTests.cs @@ -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(tenants); services.AddSingleton(pumpGate); services.AddSingleton(new FakeMlClient()); - services.AddSingleton(aiClassifier); + services.AddSingleton(aiClassifier.Classifier); // Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant // (эталон StorageTickSchedulerTests/ConnectionStringProvider.ForTenant). services.AddScoped(provider => TenantOf(provider) == TenantA ? kanjA : kanjB); diff --git a/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerServiceTests.cs index 22e27b7..1e5bb56 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerServiceTests.cs @@ -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); } diff --git a/src/core/tests/Deal.Tests.Unit/Support/TestAiClassifier.cs b/src/core/tests/Deal.Tests.Unit/Support/TestAiClassifier.cs new file mode 100644 index 0000000..37ba2da --- /dev/null +++ b/src/core/tests/Deal.Tests.Unit/Support/TestAiClassifier.cs @@ -0,0 +1,79 @@ +using Deal.Contracts.Integrations.Abstractions; +using Deal.Contracts.Integrations.Models; +using NSubstitute; + +namespace Deal.Tests.Unit.Support; + +/// +/// Подставка со сценарием состояния: сервисы получают NSubstitute-подставку +/// (), тесты управляют поведением через свойства (как раньше — через фейк). +/// +public sealed class TestAiClassifier +{ + /// + /// Ответ FilterAsync по умолчанию — как LocalAiClassifier + /// + public AiFilterResultDto FilterResult { get; set; } = new(Pass: true, Reason: null, Skipped: true); + + /// + /// Ответ ClassifyAsync; null — ClassifyAsync бросает NotSupportedException + /// + public AiParsedCardDto? ClassifyResult { get; set; } + + /// + /// Сбой FilterAsync. + /// + public bool FilterThrows { get; set; } + + /// + /// Сбой ClassifyAsync. + /// + public bool ClassifyThrows { get; set; } + + /// + /// Сколько раз вызван FilterAsync + /// + public int FilterCalls { get; private set; } + + /// + /// Сколько раз вызван ClassifyAsync + /// + public int ClassifyCalls { get; private set; } + + /// + /// Подставка порта классификатора (создаётся в конструкторе). + /// + public IAiClassifier Classifier { get; } + + /// + /// Создаёт подставку с поведением по умолчанию. + /// + public TestAiClassifier() + { + Classifier = Substitute.For(); + Classifier.When(c => c.FilterAsync(Arg.Any(), Arg.Any())) + .Do(_ => FilterCalls++); + Classifier.FilterAsync(Arg.Any(), Arg.Any()) + .Returns(_ => + { + if (FilterThrows) + { + throw new InvalidOperationException("Тестовый сбой ИИ-фильтра (FilterThrows)."); + } + + return FilterResult; + }); + Classifier.When(c => c.ClassifyAsync(Arg.Any(), Arg.Any())) + .Do(_ => ClassifyCalls++); + Classifier.ClassifyAsync(Arg.Any(), Arg.Any()) + .Returns(_ => + { + if (ClassifyThrows) + { + throw new InvalidOperationException("Тестовый сбой классификатора (ClassifyThrows)."); + } + + return ClassifyResult ?? throw new NotSupportedException(); + }); + } +}