Перевести тестовые фейки на моки NSubstitute #13

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