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();
+ });
+ }
+}