Перевести FakeMlClient на NSubstitute

Хелпер Support/TestMlClient: Status/Predict сценарии через свойства, Pushed
журнал, PredictCalls через When/Do, ResetAsync бросает NotSupportedException.
Потребители (17 файлов) перетипизированы на .Client, фейк удалён,
тесты 1340 зелёные.
This commit is contained in:
2026-09-13 01:07:43 +03:00
parent c5fd516a36
commit 3ccbe4c9f5
18 changed files with 113 additions and 109 deletions
@@ -104,7 +104,7 @@ public sealed class DiscoveryWorkerSchedulerTests
var services = new ServiceCollection();
services.AddSingleton<ITenantContext>(tenantContext);
services.AddSingleton<ITenantRepository>(tenants.Repository);
services.AddSingleton<IMlClient>(new FakeMlClient());
services.AddSingleton<IMlClient>(new TestMlClient().Client);
services.AddSingleton<IAiTools>(_ => TestAiTools.New());
// Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant
// (эталон PipelineWorkerSchedulerTests/ConnectionStringProvider.ForTenant).
@@ -154,7 +154,7 @@ public sealed class RuntimeDepthsCollectorTests
}).ToArray()).Repository);
services.AddScoped<IPipelineStore>(provider => pipelineByTenant[CurrentTenant(provider)]);
services.AddScoped<IMlClient>(_ => new FakeMlClient());
services.AddScoped<IMlClient>(_ => new TestMlClient().Client);
services.AddScoped<PipelineIngestService>();
services.AddScoped<PipelineProcessingService>();
services.AddScoped<IMlLearningStore>(provider => outboxByTenant[CurrentTenant(provider)]);
@@ -172,4 +172,4 @@ public sealed class RuntimeDepthsCollectorTests
ITenantContext context = provider.GetRequiredService<ITenantContext>();
return context.TenantId!.Value.Value;
}
}
}
@@ -84,7 +84,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_ToBoard_WritesJournalAndPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, FakeKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик"));
@@ -105,7 +105,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_ToInbox_WritesJournalWithoutPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, FakeKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "b_py", sourceMsg: "Нужен Python-разработчик", prevCol: "inbox"));
@@ -124,7 +124,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_ToUnknownBoard_Returns400Text()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, FakeKanjStore store, _, TestMlClient ml) = Create();
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик"));
CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_ghost", CancellationToken.None);
@@ -148,7 +148,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_CardMissing_ReturnsNullLead()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, FakeKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py"));
CardResultDto result = await service.MoveDashboardCardAsync("l_ghost", "b_py", CancellationToken.None);
@@ -162,7 +162,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_SameColumn_NoOpWithoutJournalAndPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, FakeKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "b_py", sourceMsg: "Нужен Python-разработчик", isNew: true));
@@ -179,7 +179,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_ToBoardWithoutRules_MatchHitsEmptyButPushSent()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, FakeKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_free")); // правил нет
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Любой текст"));
@@ -195,7 +195,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_FromBoardToBoard_PrevColIsOldBoardAndPushNewLabel()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, FakeKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_java", Rules(stack: new[] { "java" })));
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "b_java", sourceMsg: "Нужен Java-разработчик", prevCol: "inbox"));
@@ -214,7 +214,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_EmptySourceText_UsesTitleForMatchHitsAndPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, FakeKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "inbox", title: "Middle Python-разработчик", sourceMsg: " "));
@@ -228,7 +228,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_EmptySourceAndTitle_JournalWithoutPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, FakeKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py"));
store.SeedCard(Card("l_1", "inbox"));
@@ -263,7 +263,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_FromArchive_Returns400AndWritesNothing()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, FakeKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py"));
store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая вакансия", prevCol: "b_py"));
@@ -279,7 +279,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_FromTrash_Returns400AndWritesNothing()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, FakeKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py"));
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Спам-текст", prevCol: "b_py"));
@@ -296,7 +296,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Trash_FromInbox_MovesToTrashWithJournalAndSpamPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, FakeKanjStore store, _, TestMlClient ml) = Create();
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Предлагаю услуги продвижения", isNew: true));
CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None);
@@ -318,7 +318,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Trash_AlreadyInTrash_NoOpWithoutJournalAndPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, FakeKanjStore store, _, TestMlClient ml) = Create();
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст"));
CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None);
@@ -331,7 +331,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Trash_FromArchive_WritesJournalWithoutPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, FakeKanjStore store, _, TestMlClient ml) = Create();
store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая карточка", prevCol: "b_py"));
CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None);
@@ -346,7 +346,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Trash_CardMissing_ReturnsNull()
{
(CardsService service, _, _, FakeMlClient ml) = Create();
(CardsService service, _, _, TestMlClient ml) = Create();
CardDto? result = await service.TrashCardAsync("l_ghost", CancellationToken.None);
@@ -358,7 +358,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Restore_FromTrash_ToPrevColBoard_UnlearnsSpam()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, FakeKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Нужен Python-разработчик", prevCol: "b_py"));
@@ -383,7 +383,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Restore_FromArchive_NoPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, FakeKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py"));
store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая карточка", prevCol: "b_py"));
@@ -399,7 +399,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Restore_FromTrash_PrevColDeletedBoard_FallsBackToInbox()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, FakeKanjStore store, _, TestMlClient ml) = Create();
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст", prevCol: "b_gone"));
string? back = await service.RestoreCardAsync("l_1", CancellationToken.None);
@@ -618,7 +618,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Counts_ColumnsNewAndMlStats()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, FakeKanjStore store, _, TestMlClient ml) = Create();
store.SeedCard(Card("l_1", "inbox", isNew: true));
store.SeedCard(Card("l_2", "inbox"));
store.SeedCard(Card("l_3", "b_py"));
@@ -711,12 +711,12 @@ public sealed class CardsServiceTests
// ─── Хелперы ──────────────────────────────────────────────────────────
private static (CardsService Service, FakeKanjStore Store, FakeSettingsStore Settings, FakeMlClient Ml) Create()
private static (CardsService Service, FakeKanjStore Store, FakeSettingsStore Settings, TestMlClient Ml) Create()
{
var store = new FakeKanjStore();
var settings = new FakeSettingsStore();
var ml = new FakeMlClient();
return (new CardsService(store, settings, ml, new TestFileStorage().Storage), store, settings, ml);
var ml = new TestMlClient();
return (new CardsService(store, settings, ml.Client, new TestFileStorage().Storage), store, settings, ml);
}
// Доска с правилами (ContainerRulesDto) либо без них.
@@ -30,7 +30,7 @@ public sealed class DiscoveryEvaluatorTests
[Fact]
public async Task EvaluateMessage_MlSpam_NotFit()
{
(DiscoveryEvaluator evaluator, _, FakeMlClient ml, _) = Create(aiEnabled: false, mlEnabled: true);
(DiscoveryEvaluator evaluator, _, TestMlClient ml, _) = Create(aiEnabled: false, mlEnabled: true);
ml.Predict = new MlPredictResultDto(
Take: true, Label: "spam", Scores: new Dictionary<string, double>(), Hits: 3, Ready: true, Margin: 0.9,
Terms: Array.Empty<string>(), Type: null);
@@ -46,7 +46,7 @@ public sealed class DiscoveryEvaluatorTests
[Fact]
public async Task EvaluateMessage_AiFits_ReturnsAiVerdict()
{
(DiscoveryEvaluator evaluator, _, FakeMlClient ml, IAiTools ai) = Create(aiEnabled: true, mlEnabled: false);
(DiscoveryEvaluator evaluator, _, TestMlClient ml, IAiTools ai) = Create(aiEnabled: true, mlEnabled: false);
ai.ReturnsFit(new AiEvaluateFitResultDto(Fit: true, "тема совпадает"));
DiscoveryMessageFit fit = await evaluator.EvaluateMessageAsync(
@@ -168,14 +168,14 @@ public sealed class DiscoveryEvaluatorTests
=> new(id, text, DateMs: 1_700_000_000_000 + id, topic, TopicTitle: null);
// Создаёт оценщик на фейках: флаги веток выставляются сценарием.
private static (DiscoveryEvaluator Evaluator, FakeSettingsStore Settings, FakeMlClient Ml, IAiTools Ai) Create(bool aiEnabled, bool mlEnabled)
private static (DiscoveryEvaluator Evaluator, FakeSettingsStore Settings, TestMlClient Ml, IAiTools Ai) Create(bool aiEnabled, bool mlEnabled)
{
var settings = new FakeSettingsStore();
settings.Preload(SettingsKeys.AiEnabled, aiEnabled ? "true" : "false");
settings.Preload(SettingsKeys.MlEnabled, mlEnabled ? "true" : "false");
var ml = new FakeMlClient();
var ml = new TestMlClient();
var ai = TestAiTools.New();
return (new DiscoveryEvaluator(settings, ml, ai), settings, ml, ai);
return (new DiscoveryEvaluator(settings, ml.Client, ai), settings, ml, ai);
}
// Задача как строка DiscTasks (адаптер-форма; нужны только keywords/description/threshold).
@@ -432,7 +432,7 @@ public sealed class DiscoveryWorkerServiceTests
var settings = new FakeSettingsStore();
settings.Preload(SettingsKeys.MlEnabled, "false");
settings.Preload(SettingsKeys.AiEnabled, "false");
var ml = new FakeMlClient();
var ml = new TestMlClient();
var ai = TestAiTools.New();
var log = new DiscoveryLogService(store.Store);
var blacklist = new DiscoveryBlacklistService(store.Store);
@@ -443,7 +443,7 @@ public sealed class DiscoveryWorkerServiceTests
var pacer = Substitute.For<IDiscoveryPacer>();
var gateway = new TestDiscoveryGateway();
var worker = new DiscoveryWorkerService(
store.Store, tasks, candidates, log, new DiscoveryEvaluator(settings, ml, ai), banGuard, pacer, gateway.Gateway);
store.Store, tasks, candidates, log, new DiscoveryEvaluator(settings, ml.Client, ai), banGuard, pacer, gateway.Gateway);
return new Fixture(store, settings, gateway, pacer, worker, tasks);
}
@@ -167,7 +167,7 @@ public sealed class PipelineWorkerGrpcAiTests
var settings = new FakeSettingsStore();
var pipelineStore = new TestPipelineStore();
var kanjStore = new FakeKanjStore();
var mlClient = new FakeMlClient { Predict = NotReadyPrediction() };
var mlClient = new TestMlClient { Predict = NotReadyPrediction() };
var rules = new IncomingRules(settings);
var fieldsParser = new LocalFieldsParser(settings);
@@ -192,11 +192,11 @@ public sealed class PipelineWorkerGrpcAiTests
NullLogger<BudgetedAiClassifier>.Instance)
: grpcClassifier;
var processing = new PipelineProcessingService(pipelineStore.Store, mlClient, new PipelineIngestService(pipelineStore.Store));
var processing = new PipelineProcessingService(pipelineStore.Store, mlClient.Client, new PipelineIngestService(pipelineStore.Store));
var composer = new CardComposer(kanjStore, settings);
var writer = new PipelineCardWriter(kanjStore, pipelineStore.Store, composer);
var worker = new PipelineWorkerService(
pipelineStore.Store, settings, rules, kanjStore, mlClient, aiClassifier, processing, writer, fieldsParser);
pipelineStore.Store, settings, rules, kanjStore, mlClient.Client, aiClassifier, processing, writer, fieldsParser);
return new Context(worker, pipelineStore, kanjStore, settings, limits);
}
@@ -82,7 +82,7 @@ public sealed class CardMoverTests
private static (CardMover Mover, FakeKanjStore Store) Create()
{
var store = new FakeKanjStore();
var cardsService = new CardsService(store, new FakeSettingsStore(), new FakeMlClient(), new TestFileStorage().Storage);
var cardsService = new CardsService(store, new FakeSettingsStore(), new TestMlClient().Client, new TestFileStorage().Storage);
return (new CardMover(cardsService), store);
}
}
@@ -195,7 +195,7 @@ public sealed class CardsServiceRemindersTests
settings.Preload(SettingsKeys.RemindersEnabled, "false");
}
return (new CardsService(store, settings, new FakeMlClient(), new TestFileStorage().Storage), store, settings);
return (new CardsService(store, settings, new TestMlClient().Client, new TestFileStorage().Storage), store, settings);
}
// Текущее время в epoch-мс (UTC) — для посева напоминаний в прошлом/будущем.
@@ -21,13 +21,13 @@ public sealed class MlReviewServiceTests
private static MlReviewService Create(
TestPipelineStore pipeline,
FakeKanjStore kanj,
FakeMlClient ml,
TestMlClient ml,
out CardsService cards)
{
var settings = new FakeSettingsStore();
cards = new CardsService(kanj, settings, ml, new TestFileStorage().Storage);
var processing = new PipelineProcessingService(pipeline.Store, ml, new PipelineIngestService(pipeline.Store));
return new MlReviewService(pipeline.Store, kanj, cards, processing, ml);
cards = new CardsService(kanj, settings, ml.Client, new TestFileStorage().Storage);
var processing = new PipelineProcessingService(pipeline.Store, ml.Client, new PipelineIngestService(pipeline.Store));
return new MlReviewService(pipeline.Store, kanj, cards, processing, ml.Client);
}
private static QueueItemDto QueueRow(
@@ -78,7 +78,7 @@ public sealed class MlReviewServiceTests
pipeline.SeedRejected(RejectedRow(102, "из отсева"));
var kanj = new FakeKanjStore();
kanj.SeedCard(Card(103, "из карточки"));
MlReviewService service = Create(pipeline, kanj, new FakeMlClient(), out _);
MlReviewService service = Create(pipeline, kanj, new TestMlClient(), out _);
IReadOnlyList<MlCandidateDto> items = await service.CandidatesAsync(Dialog, 10, CancellationToken.None);
@@ -97,7 +97,7 @@ public sealed class MlReviewServiceTests
var pipeline = new TestPipelineStore();
pipeline.SeedQueue(QueueRow(101, "нужный", Dialog));
pipeline.SeedQueue(QueueRow(201, "другой", "d_2"));
MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _);
MlReviewService service = Create(pipeline, new FakeKanjStore(), new TestMlClient(), out _);
IReadOnlyList<MlCandidateDto> items = await service.CandidatesAsync(Dialog, 10, CancellationToken.None);
@@ -111,7 +111,7 @@ public sealed class MlReviewServiceTests
var pipeline = new TestPipelineStore();
pipeline.SeedQueue(QueueRow(101, "a", "d_1"));
pipeline.SeedQueue(QueueRow(201, "b", "d_2"));
MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _);
MlReviewService service = Create(pipeline, new FakeKanjStore(), new TestMlClient(), out _);
IReadOnlyList<MlCandidateDto> items = await service.CandidatesAsync(null, 10, CancellationToken.None);
@@ -127,7 +127,7 @@ public sealed class MlReviewServiceTests
pipeline.SeedQueue(QueueRow(100 + i, $"текст {i}"));
}
MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _);
MlReviewService service = Create(pipeline, new FakeKanjStore(), new TestMlClient(), out _);
IReadOnlyList<MlCandidateDto> items = await service.CandidatesAsync(Dialog, 2, CancellationToken.None);
@@ -139,7 +139,7 @@ public sealed class MlReviewServiceTests
{
var pipeline = new TestPipelineStore();
pipeline.SeedQueue(QueueRow(101, "текст"));
var ml = new FakeMlClient();
var ml = new TestMlClient();
MlReviewService service = Create(pipeline, new FakeKanjStore(), ml, out _);
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSkip, CancellationToken.None);
@@ -156,7 +156,7 @@ public sealed class MlReviewServiceTests
{
var kanj = new FakeKanjStore();
kanj.SeedCard(Card(101, "спамный текст"));
var ml = new FakeMlClient();
var ml = new TestMlClient();
MlReviewService service = Create(new TestPipelineStore(), kanj, ml, out _);
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSpam, CancellationToken.None);
@@ -174,7 +174,7 @@ public sealed class MlReviewServiceTests
{
var pipeline = new TestPipelineStore();
pipeline.SeedQueue(QueueRow(101, "рекламный текст"));
var ml = new FakeMlClient();
var ml = new TestMlClient();
MlReviewService service = Create(pipeline, new FakeKanjStore(), ml, out _);
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSpam, CancellationToken.None);
@@ -201,7 +201,7 @@ public sealed class MlReviewServiceTests
Kind = ContainerKinds.Board,
});
kanj.SeedCard(Card(101, "python разработчик"));
var ml = new FakeMlClient();
var ml = new TestMlClient();
MlReviewService service = Create(new TestPipelineStore(), kanj, ml, out _);
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "board:b_py", CancellationToken.None);
@@ -218,7 +218,7 @@ public sealed class MlReviewServiceTests
{
var kanj = new FakeKanjStore();
kanj.SeedCard(Card(101, "текст"));
MlReviewService service = Create(new TestPipelineStore(), kanj, new FakeMlClient(), out _);
MlReviewService service = Create(new TestPipelineStore(), kanj, new TestMlClient(), out _);
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "board:missing", CancellationToken.None);
@@ -232,7 +232,7 @@ public sealed class MlReviewServiceTests
{
var pipeline = new TestPipelineStore();
pipeline.SeedQueue(QueueRow(101, "текст"));
MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _);
MlReviewService service = Create(pipeline, new FakeKanjStore(), new TestMlClient(), out _);
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "что-то", CancellationToken.None);
@@ -244,7 +244,7 @@ public sealed class MlReviewServiceTests
[Fact]
public async Task Apply_MessageNotFound_ReturnsNull()
{
MlReviewService service = Create(new TestPipelineStore(), new FakeKanjStore(), new FakeMlClient(), out _);
MlReviewService service = Create(new TestPipelineStore(), new FakeKanjStore(), new TestMlClient(), out _);
MlApplyResult? result = await service.ApplyAsync(Dialog, 999, MlReviewService.ActionSpam, CancellationToken.None);
@@ -168,7 +168,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task ReturnAsync_RecordNotFound_ReturnsNull()
{
(PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create();
(PipelineProcessingService service, FakePipelineStore store, TestMlClient ml) = Create();
RejectReturnResultDto? result = await service.ReturnAsync("r_missing", string.Empty, CancellationToken.None);
@@ -180,7 +180,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task ReturnAsync_AlreadyReturned_Returns400()
{
(PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create();
(PipelineProcessingService service, FakePipelineStore store, TestMlClient ml) = Create();
store.SeedRejected(Rejected("r_1", returned: true, returnReason: "было"));
RejectReturnResultDto? result = await service.ReturnAsync("r_1", "ещё раз", CancellationToken.None);
@@ -195,7 +195,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task ReturnAsync_DuplicateSource_Returns400()
{
(PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create();
(PipelineProcessingService service, FakePipelineStore store, TestMlClient ml) = Create();
store.SeedRejected(Rejected("r_1", decidedBy: "dup", stage: "dup"));
RejectReturnResultDto? result = await service.ReturnAsync("r_1", string.Empty, CancellationToken.None);
@@ -209,7 +209,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task ReturnAsync_EmptyText_Returns400()
{
(PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create();
(PipelineProcessingService service, FakePipelineStore store, TestMlClient ml) = Create();
store.SeedRejected(Rejected("r_1", text: " "));
RejectReturnResultDto? result = await service.ReturnAsync("r_1", string.Empty, CancellationToken.None);
@@ -223,7 +223,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task ReturnAsync_SpamStage_UnlearnsSpamMarksRecordAndEnqueuesForceRow()
{
(PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create();
(PipelineProcessingService service, FakePipelineStore store, TestMlClient ml) = Create();
store.SeedRejected(Rejected(
"r_1",
text: " Продвижение в каналах ",
@@ -270,7 +270,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task ReturnAsync_NonSpamStage_NoLearningPushButEnqueuesForceRow()
{
(PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create();
(PipelineProcessingService service, FakePipelineStore store, TestMlClient ml) = Create();
store.SeedRejected(Rejected(
"r_1",
text: "Короткое сообщение",
@@ -309,7 +309,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task ReturnAsync_NoSourceInfo_EnqueuesDirectlyWithDefaultHue()
{
(PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create();
(PipelineProcessingService service, FakePipelineStore store, TestMlClient ml) = Create();
store.SeedRejected(Rejected(
"r_old",
text: "Старая запись без ссылки на исходное",
@@ -382,11 +382,11 @@ public sealed class PipelineProcessingServiceTests
// ─── Помощники ──────────────────────────────────────────────────────────
// Сервис на общих фейках: очередь/отсев и ML-клиент одного сценария.
private static (PipelineProcessingService Service, FakePipelineStore Store, FakeMlClient Ml) Create()
private static (PipelineProcessingService Service, FakePipelineStore Store, TestMlClient Ml) Create()
{
FakePipelineStore store = new();
FakeMlClient ml = new();
PipelineProcessingService service = new(store, ml, new PipelineIngestService(store));
TestMlClient ml = new();
PipelineProcessingService service = new(store, ml.Client, new PipelineIngestService(store));
return (service, store, ml);
}
@@ -3,6 +3,7 @@ using Deal.Api.Dtos;
using Deal.Api.Events;
using Deal.Api.Services;
using Deal.Contracts.Integrations.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Pipeline.Application.Models;
@@ -195,7 +196,7 @@ public sealed class AdminTickOrchestratorTests
// Собирает оркестратор на общих фейках: StorageTickService и воркер делят KanjStore/Settings,
// обработка и воркер — PipelineStore, напоминания — тот же KanjStore/Settings (CardsService);
// ML «не готов» (FakeMlClient.Predict не задан → сбой → «не уверен»).
// ML «не готов» (TestMlClient.Predict не задан → сбой → «не уверен»).
// withThrowingQueueRead: true — чтение очереди воркером бросает (сценарий сбоя pump).
// withThrowingReminderCheck: true — выборка due-напоминаний бросает (сценарий сбоя проверки).
// Возвращает: Оркестратор, фейки и подписку канала тенанта.
@@ -211,23 +212,23 @@ public sealed class AdminTickOrchestratorTests
throw new InvalidOperationException("Тестовый сбой чтения очереди (ListAsync)."));
}
var kanjStore = new FakeKanjStore();
var mlClient = new FakeMlClient();
var mlClient = new TestMlClient();
var aiClassifier = new TestAiClassifier();
var rules = new IncomingRules(settings);
var fieldsParser = new LocalFieldsParser(settings);
var ingest = new PipelineIngestService(store.Store);
var processing = new PipelineProcessingService(store.Store, mlClient, ingest);
var processing = new PipelineProcessingService(store.Store, mlClient.Client, ingest);
var composer = new CardComposer(kanjStore, settings);
var writer = new PipelineCardWriter(kanjStore, store.Store, composer);
var worker = new PipelineWorkerService(
store.Store, settings, rules, kanjStore, mlClient, aiClassifier.Classifier, processing, writer, fieldsParser);
store.Store, settings, rules, kanjStore, mlClient.Client, aiClassifier.Classifier, processing, writer, fieldsParser);
var broker = new SseBroker();
var tickService = new StorageTickService(kanjStore, settings);
var toastPublisher = new StorageToastPublisher(broker);
var pumpGate = new PipelinePumpGate();
FakeKanjStore reminderStore = withThrowingReminderCheck ? new ThrowingDueKanjStore() : kanjStore;
var cardsService = new CardsService(reminderStore, settings, mlClient, new TestFileStorage().Storage);
var cardsService = new CardsService(reminderStore, settings, mlClient.Client, new TestFileStorage().Storage);
var orchestrator = new AdminTickOrchestrator(
tickService, processing, worker, cardsService, toastPublisher, broker, pumpGate,
NullLogger<AdminTickOrchestrator>.Instance);
@@ -1,5 +1,6 @@
using System.Text.Json;
using Deal.Contracts.Integrations.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
@@ -23,7 +24,7 @@ public sealed class CardReclassifierTests
CardReclassifier Reclassifier,
FakeKanjStore Store,
FakeSettingsStore Settings,
FakeMlClient MlClient,
TestMlClient MlClient,
TestAiClassifier AiClassifier,
ReclassifyGate Gate);
@@ -33,14 +34,14 @@ public sealed class CardReclassifierTests
{
var settings = new FakeSettingsStore();
var store = new FakeKanjStore();
var mlClient = new FakeMlClient();
var mlClient = new TestMlClient();
var aiClassifier = new TestAiClassifier();
var fieldsParser = new LocalFieldsParser(settings);
var composer = new CardComposer(store, settings);
var cardsService = new CardsService(store, settings, mlClient, new TestFileStorage().Storage);
var cardsService = new CardsService(store, settings, mlClient.Client, new TestFileStorage().Storage);
var gate = new ReclassifyGate();
var reclassifier = new CardReclassifier(
store, settings, aiClassifier.Classifier, fieldsParser, composer, cardsService, mlClient, gate);
store, settings, aiClassifier.Classifier, fieldsParser, composer, cardsService, mlClient.Client, gate);
return new Context(reclassifier, store, settings, mlClient, aiClassifier, gate);
}
@@ -242,7 +242,7 @@ public sealed class CardsServiceFilesTests
{
var store = new FakeKanjStore();
var storage = new TestFileStorage();
return (new CardsService(store, new FakeSettingsStore(), new FakeMlClient(), storage.Storage), store, storage);
return (new CardsService(store, new FakeSettingsStore(), new TestMlClient().Client, storage.Storage), store, storage);
}
// Карточка с полями по умолчанию (planned, CreatedAtMs=1, UpdatedAtMs=1; Files пуст).
@@ -531,12 +531,12 @@ public sealed class CardsServiceSelectedTests
// ─── Хелперы ──────────────────────────────────────────────────────────
private static (CardsService Service, FakeKanjStore Store, FakeSettingsStore Settings, FakeMlClient Ml) Create()
private static (CardsService Service, FakeKanjStore Store, FakeSettingsStore Settings, TestMlClient Ml) Create()
{
var store = new FakeKanjStore();
var settings = new FakeSettingsStore();
var ml = new FakeMlClient();
return (new CardsService(store, settings, ml, new TestFileStorage().Storage), store, settings, ml);
var ml = new TestMlClient();
return (new CardsService(store, settings, ml.Client, new TestFileStorage().Storage), store, settings, ml);
}
// Тело PATCH из пар «ключ → значение» (presence = наличие пары; null — явный JSON-null).
@@ -3,6 +3,7 @@ using Deal.Api.Events;
using Deal.Api.Services;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Tests.Unit.Support;
using Deal.Infrastructure.Data;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models;
@@ -167,7 +168,7 @@ public sealed class PipelineWorkerSchedulerTests
services.AddSingleton<SseBroker>();
services.AddSingleton<ITenantRepository>(tenants.Repository);
services.AddSingleton(pumpGate);
services.AddSingleton<IMlClient>(new FakeMlClient());
services.AddSingleton<IMlClient>(new TestMlClient().Client);
services.AddSingleton<IAiClassifier>(aiClassifier.Classifier);
// Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant
// (эталон StorageTickSchedulerTests/ConnectionStringProvider.ForTenant).
@@ -1,5 +1,6 @@
using System.Text.Json;
using Deal.Contracts.Integrations.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Parse;
@@ -27,7 +28,7 @@ public sealed class PipelineWorkerServiceTests
TestPipelineStore PipelineStore,
FakeKanjStore KanjStore,
FakeSettingsStore Settings,
FakeMlClient MlClient,
TestMlClient MlClient,
TestAiClassifier AiClassifier);
// Собирает контекст: дефолты настроек/досок, ML «не готов» (как LocalMlClient), ИИ-фильтр пропускает.
@@ -38,15 +39,15 @@ public sealed class PipelineWorkerServiceTests
var settings = new FakeSettingsStore();
var store = pipelineStore ?? new TestPipelineStore();
var kanjStore = new FakeKanjStore();
var mlClient = new FakeMlClient { Predict = NotReadyPrediction() };
var mlClient = new TestMlClient { Predict = NotReadyPrediction() };
var aiClassifier = new TestAiClassifier();
var rules = new IncomingRules(settings);
var fieldsParser = new LocalFieldsParser(settings);
var processing = new PipelineProcessingService(store.Store, mlClient, new PipelineIngestService(store.Store));
var processing = new PipelineProcessingService(store.Store, mlClient.Client, new PipelineIngestService(store.Store));
var composer = new CardComposer(kanjStore, settings);
var writer = new PipelineCardWriter(kanjStore, store.Store, composer);
var worker = new PipelineWorkerService(
store.Store, settings, rules, kanjStore, mlClient, aiClassifier.Classifier, processing, writer, fieldsParser);
store.Store, settings, rules, kanjStore, mlClient.Client, aiClassifier.Classifier, processing, writer, fieldsParser);
return new Context(worker, store, kanjStore, settings, mlClient, aiClassifier);
}
@@ -322,7 +322,7 @@ public sealed class StorageTickSchedulerTests
services.AddScoped<ICardStore>(provider => storesByTenant[TenantOf(provider)]);
services.AddScoped<ISettingsStore>(provider => settingsByTenant[TenantOf(provider)]);
services.AddScoped<IPipelineStore>(provider => pipelineStoresByTenant[TenantOf(provider)].Store);
services.AddSingleton<IMlClient>(new FakeMlClient());
services.AddSingleton<IMlClient>(new TestMlClient().Client);
services.AddSingleton<IFileStorage>(new TestFileStorage().Storage);
services.AddScoped<PipelineIngestService>();
services.AddScoped<PipelineProcessingService>();
@@ -1,15 +1,22 @@
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using NSubstitute;
namespace Deal.Tests.Unit.Contracts;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// In-memory реализация <see cref="IMlClient"/> для unit-тестов CardsService и воркера pump.
/// Подставка <see cref="IMlClient"/>: сервисы получают NSubstitute-подставку (<see cref="Client"/>),
/// тесты управляют сценариями через свойства (как раньше — через фейк).
/// </summary>
public sealed class FakeMlClient : IMlClient
public sealed class TestMlClient
{
private readonly List<(string Text, string Label, double Delta)> _pushed = [];
/// <summary>
/// Подставка порта ML-клиента (создаётся в конструкторе).
/// </summary>
public IMlClient Client { get; }
/// <summary>
/// Ответ StatusAsync
/// </summary>
@@ -26,37 +33,30 @@ public sealed class FakeMlClient : IMlClient
public int PredictCalls { get; private set; }
/// <summary>
/// Обучающие сигналы, отправленные через <see cref="PushAsync"/>
/// Обучающие сигналы, отправленные через <see cref="IMlClient.PushAsync"/>
/// </summary>
public IReadOnlyList<(string Text, string Label, double Delta)> Pushed => _pushed.ToList();
/// <inheritdoc />
public Task<MlStatusResponseDto> StatusAsync(CancellationToken ct) => Task.FromResult(Status);
/// <inheritdoc />
public Task PushAsync(
string text,
string label,
double delta,
CancellationToken ct)
/// <summary>
/// Создаёт подставку с поведением по умолчанию.
/// </summary>
public TestMlClient()
{
_pushed.Add((text, label, delta));
return Task.CompletedTask;
Client = Substitute.For<IMlClient>();
Client.StatusAsync(Arg.Any<CancellationToken>())
.Returns(ci => Status);
Client.When(c => c.PushAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<double>(), Arg.Any<CancellationToken>()))
.Do(ci => _pushed.Add((ci.ArgAt<string>(0), ci.ArgAt<string>(1), ci.ArgAt<double>(2))));
Client.PredictAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns<MlPredictResultDto>(ci =>
{
PredictCalls++;
return Predict ?? throw new NotSupportedException();
});
Client.ResetAsync(Arg.Any<CancellationToken>())
.Returns<MlResetResultDto>(_ => throw new NotSupportedException());
}
/// <inheritdoc />
public Task<MlPredictResultDto> PredictAsync(string text, CancellationToken ct)
{
PredictCalls++;
return Predict is null
? throw new NotSupportedException()
: Task.FromResult(Predict);
}
/// <inheritdoc />
public Task<MlResetResultDto> ResetAsync(CancellationToken ct) =>
throw new NotSupportedException();
// Статус по умолчанию: модель не готова, все счётчики 0 (форма MlStatusResponseDto).
// Возвращает: Пустой статус (ml/ai/learning = 0), как у LocalMlClient до действий.
private static MlStatusResponseDto DefaultStatus()