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; using Deal.Tests.Unit.Modules.Settings; namespace Deal.Tests.Unit.Contracts; /// /// Тесты CardsService — карточки /// public sealed class CardsServiceTests { [Fact] public async Task ListCards_NoCol_ReturnsAllOrderedByReceivedAtDesc() { (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("l_1", "inbox", receivedAtMs: 100)); store.SeedCard(Card("l_2", "b_x", receivedAtMs: 300)); IReadOnlyList cards = await service.ListCardsAsync(col: null, CancellationToken.None); // Без фильтра — все карточки дашборда; порядок — received_at DESC. Assert.Equal(new[] { "l_2", "l_1" }, cards.Select(card => card.Id)); } [Fact] public async Task ListCards_ByColumn_ReturnsOnlyColumnCards() { (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("l_1", "inbox")); store.SeedCard(Card("l_2", KanbanColumns.Trash)); IReadOnlyList cards = await service.ListCardsAsync(KanbanColumns.Trash, CancellationToken.None); CardDto card = Assert.Single(cards); Assert.Equal("l_2", card.Id); } [Fact] public async Task GetCard_Existing_ReturnsCard() { (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("l_1", "inbox", title: "Middle Python")); CardDto? card = await service.GetCardAsync("l_1", CancellationToken.None); Assert.NotNull(card); Assert.Equal("Middle Python", card!.Title); } [Fact] public async Task GetCard_Missing_ReturnsNull() { (CardsService service, _, _, _) = Create(); CardDto? card = await service.GetCardAsync("l_missing", CancellationToken.None); Assert.Null(card); } [Fact] public async Task Move_ToBoardWithRules_UpdatesColumnAndComputesMatchHits() { (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" }))); store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Middle Python-разработчик на бота, 1600$.")); CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None); Assert.Null(result.Error); Assert.Equal("b_py", result.Card!.Col); Assert.False(result.Card.IsNew); // перенос снимает «новое» Assert.Equal("inbox", result.Card.PrevCol); // prev_col = прежняя колонка MatchHitDto hit = Assert.Single(result.Card.MatchHits); // «почему в колонке» (Ruling 2) Assert.Equal("Стек", hit.Label); Assert.Equal("python", hit.Term); } [Fact] public async Task Move_ToBoard_WritesJournalAndPush() { (CardsService service, TestKanjStore store, _, TestMlClient ml) = Create(); store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" }))); store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик")); await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None); CardMoveDto move = Assert.Single(store.Moves); // журнал learning_log (Ruling 4) Assert.StartsWith("lm_", move.Id); Assert.Equal("l_1", move.LeadId); Assert.Equal("move", move.Action); Assert.Equal("inbox", move.FromCol); Assert.Equal("b_py", move.ToCol); (string text, string label, double delta) = Assert.Single(ml.Pushed); // обучение всегда (Ruling 4) Assert.Equal("Нужен Python-разработчик", text); Assert.Equal("b_py", label); Assert.Equal(1.0, delta); } [Fact] public async Task Move_ToInbox_WritesJournalWithoutPush() { (CardsService service, TestKanjStore store, _, TestMlClient ml) = Create(); store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" }))); store.SeedCard(Card("l_1", "b_py", sourceMsg: "Нужен Python-разработчик", prevCol: "inbox")); CardResultDto result = await service.MoveDashboardCardAsync("l_1", "inbox", CancellationToken.None); Assert.Null(result.Error); Assert.Equal("inbox", result.Card!.Col); CardMoveDto move = Assert.Single(store.Moves); Assert.Equal("move", move.Action); Assert.Equal("b_py", move.FromCol); Assert.Equal("inbox", move.ToCol); Assert.Empty(ml.Pushed); // «Неразобранное» не учим (Ruling 4, L188) Assert.Empty(result.Card.MatchHits); // inbox — служебная колонка, hits пусто (Ruling 2) } [Fact] public async Task Move_ToUnknownBoard_Returns400Text() { (CardsService service, TestKanjStore store, _, TestMlClient ml) = Create(); store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик")); CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_ghost", CancellationToken.None); Assert.Equal(CardsService.MoveTargetInvalidDetail, result.Error); // 400 «Переносить можно только…» Assert.Null(result.Card); Assert.Empty(store.Moves); Assert.Empty(ml.Pushed); } [Fact] public async Task Move_UnknownBoardAndMissingCard_Returns400TextFirst() { (CardsService service, _, _, _) = Create(); CardResultDto result = await service.MoveDashboardCardAsync("l_ghost", "b_ghost", CancellationToken.None); Assert.Equal(CardsService.MoveTargetInvalidDetail, result.Error); } [Fact] public async Task Move_CardMissing_ReturnsNullLead() { (CardsService service, TestKanjStore store, _, TestMlClient ml) = Create(); store.SeedBoard(Board("b_py")); CardResultDto result = await service.MoveDashboardCardAsync("l_ghost", "b_py", CancellationToken.None); Assert.Null(result.Error); Assert.Null(result.Card); // эндпоинт отвечает 404 «Карточка не найдена» Assert.Empty(store.Moves); Assert.Empty(ml.Pushed); } [Fact] public async Task Move_SameColumn_NoOpWithoutJournalAndPush() { (CardsService service, TestKanjStore store, _, TestMlClient ml) = Create(); store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" }))); store.SeedCard(Card("l_1", "b_py", sourceMsg: "Нужен Python-разработчик", isNew: true)); CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None); Assert.Null(result.Error); Assert.NotNull(result.Card); Assert.Equal("b_py", result.Card!.Col); Assert.True(result.Card.IsNew); // флаг не тронут Assert.Empty(store.Moves); Assert.Empty(ml.Pushed); } [Fact] public async Task Move_ToBoardWithoutRules_MatchHitsEmptyButPushSent() { (CardsService service, TestKanjStore store, _, TestMlClient ml) = Create(); store.SeedBoard(Board("b_free")); // правил нет store.SeedCard(Card("l_1", "inbox", sourceMsg: "Любой текст")); CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_free", CancellationToken.None); Assert.Null(result.Error); Assert.Equal("b_free", result.Card!.Col); Assert.Empty(result.Card.MatchHits); // доска без активных правил → [] (Ruling 2) Assert.Single(ml.Pushed); Assert.Equal("b_free", Assert.Single(ml.Pushed).Label); } [Fact] public async Task Move_FromBoardToBoard_PrevColIsOldBoardAndPushNewLabel() { (CardsService service, TestKanjStore 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")); CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None); Assert.Equal("b_py", result.Card!.Col); Assert.Equal("b_java", result.Card.PrevCol); CardMoveDto move = Assert.Single(store.Moves); Assert.Equal("b_java", move.FromCol); Assert.Equal("b_py", move.ToCol); (string _, string label, double _) = Assert.Single(ml.Pushed); Assert.Equal("b_py", label); // учим НОВУЮ колонку (L188–191) } [Fact] public async Task Move_EmptySourceText_UsesTitleForMatchHitsAndPush() { (CardsService service, TestKanjStore store, _, TestMlClient ml) = Create(); store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" }))); store.SeedCard(Card("l_1", "inbox", title: "Middle Python-разработчик", sourceMsg: " ")); CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None); Assert.Single(result.Card!.MatchHits); (string text, string _, double _) = Assert.Single(ml.Pushed); Assert.Equal("Middle Python-разработчик", text); } [Fact] public async Task Move_EmptySourceAndTitle_JournalWithoutPush() { (CardsService service, TestKanjStore store, _, TestMlClient ml) = Create(); store.SeedBoard(Board("b_py")); store.SeedCard(Card("l_1", "inbox")); await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None); Assert.Single(store.Moves); // перенос записан Assert.Empty(ml.Pushed); // пустой текст не учим (L190–191: if text) } [Fact] public async Task Move_ToBoardWithGradeAndBudget_HitsIncludeGradeWordAndBudget() { (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedBoard(Board("b_mid", Rules( grade: new[] { "middle" }, budget: new BudgetRangeDto(1000, 3000, "USD")))); store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен мидл разработчик, 1 600–2 200$.")); CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_mid", CancellationToken.None); Assert.Equal(2, result.Card!.MatchHits.Count); MatchHitDto grade = result.Card.MatchHits[0]; Assert.Equal("Грейд/уровень", grade.Label); Assert.Equal("middle", grade.Term); Assert.Equal("мидл", grade.Word); // найденный синоним MatchHitDto budget = result.Card.MatchHits[1]; Assert.Equal("Бюджет", budget.Label); // сумма в USD в диапазоне 1000–3000 USD } // ─── Move из служебных колонок запрещён (code-review: вывод — только restore) ── [Fact] public async Task Move_FromArchive_Returns400AndWritesNothing() { (CardsService service, TestKanjStore store, _, TestMlClient ml) = Create(); store.SeedBoard(Board("b_py")); store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая вакансия", prevCol: "b_py")); CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None); Assert.Equal(CardsService.MoveSourceRestrictedDetail, result.Error); Assert.Null(result.Card); Assert.Equal(KanbanColumns.Archive, store.CardDtos.Single().Col); // карточка не тронута Assert.Empty(store.Moves); // журнал не пишется Assert.Empty(ml.Pushed); // обучение не шлётся } [Fact] public async Task Move_FromTrash_Returns400AndWritesNothing() { (CardsService service, TestKanjStore store, _, TestMlClient ml) = Create(); store.SeedBoard(Board("b_py")); store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Спам-текст", prevCol: "b_py")); CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None); Assert.Equal(CardsService.MoveSourceRestrictedDetail, result.Error); Assert.Null(result.Card); Assert.Equal(KanbanColumns.Trash, store.CardDtos.Single().Col); Assert.Empty(store.Moves); Assert.Empty(ml.Pushed); } [Fact] public async Task Trash_FromInbox_MovesToTrashWithJournalAndSpamPush() { (CardsService service, TestKanjStore store, _, TestMlClient ml) = Create(); store.SeedCard(Card("l_1", "inbox", sourceMsg: "Предлагаю услуги продвижения", isNew: true)); CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None); Assert.Equal(KanbanColumns.Trash, result!.Col); Assert.False(result.IsNew); Assert.Equal("inbox", result.PrevCol); Assert.Empty(result.MatchHits); // корзина — служебная, hits пусто (Ruling 2) CardMoveDto move = Assert.Single(store.Moves); Assert.Equal("trash", move.Action); Assert.Equal("inbox", move.FromCol); Assert.Equal(KanbanColumns.Trash, move.ToCol); (string text, string label, double delta) = Assert.Single(ml.Pushed); // «в корзину» = спам (Ruling 4) Assert.Equal("Предлагаю услуги продвижения", text); Assert.Equal("spam", label); Assert.Equal(1.0, delta); } [Fact] public async Task Trash_AlreadyInTrash_NoOpWithoutJournalAndPush() { (CardsService service, TestKanjStore store, _, TestMlClient ml) = Create(); store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст")); CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None); Assert.NotNull(result); Assert.Empty(store.Moves); // _move L165–166: в ту же колонку не переносим Assert.Empty(ml.Pushed); } [Fact] public async Task Trash_FromArchive_WritesJournalWithoutPush() { (CardsService service, TestKanjStore store, _, TestMlClient ml) = Create(); store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая карточка", prevCol: "b_py")); CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None); Assert.Equal(KanbanColumns.Trash, result!.Col); CardMoveDto move = Assert.Single(store.Moves); Assert.Equal("trash", move.Action); Assert.Equal(KanbanColumns.Archive, move.FromCol); Assert.Empty(ml.Pushed); // карточка уже в archive — не «переучиваем» на спам (L198) } [Fact] public async Task Trash_CardMissing_ReturnsNull() { (CardsService service, _, _, TestMlClient ml) = Create(); CardDto? result = await service.TrashCardAsync("l_ghost", CancellationToken.None); Assert.Null(result); // эндпоинт отвечает 404 «Карточка не найдена» Assert.Empty(ml.Pushed); } [Fact] public async Task Restore_FromTrash_ToPrevColBoard_UnlearnsSpam() { (CardsService service, TestKanjStore 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")); string? back = await service.RestoreCardAsync("l_1", CancellationToken.None); Assert.Equal("b_py", back); // ответ {ok, col} CardDto card = Assert.Single(store.CardDtos); Assert.Equal("b_py", card.Col); Assert.True(card.IsNew); // возврат — снова «новое» (L213) Assert.Equal("inbox", card.PrevCol); // restore ставит prev_col='inbox' (Ruling 10) Assert.Single(card.MatchHits); // hits пересчитаны для доски (Ruling 2) CardMoveDto move = Assert.Single(store.Moves); Assert.Equal("restore", move.Action); Assert.Equal(KanbanColumns.Trash, move.FromCol); Assert.Equal("b_py", move.ToCol); (string text, string label, double delta) = Assert.Single(ml.Pushed); // возврат из корзины = не спам (L218–221) Assert.Equal("Нужен Python-разработчик", text); Assert.Equal("spam", label); Assert.Equal(-1.0, delta); } [Fact] public async Task Restore_FromArchive_NoPush() { (CardsService service, TestKanjStore store, _, TestMlClient ml) = Create(); store.SeedBoard(Board("b_py")); store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая карточка", prevCol: "b_py")); string? back = await service.RestoreCardAsync("l_1", CancellationToken.None); Assert.Equal("b_py", back); CardMoveDto move = Assert.Single(store.Moves); Assert.Equal("restore", move.Action); Assert.Equal(KanbanColumns.Archive, move.FromCol); Assert.Empty(ml.Pushed); // только возврат ИЗ корзины снимает метку spam (L218) } [Fact] public async Task Restore_FromTrash_PrevColDeletedBoard_FallsBackToInbox() { (CardsService service, TestKanjStore store, _, TestMlClient ml) = Create(); store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст", prevCol: "b_gone")); string? back = await service.RestoreCardAsync("l_1", CancellationToken.None); Assert.Equal("inbox", back); CardDto card = Assert.Single(store.CardDtos); Assert.Equal("inbox", card.Col); Assert.Empty(card.MatchHits); Assert.Equal(-1.0, Assert.Single(ml.Pushed).Delta); // из корзины — сигнал всё равно снимается } [Fact] public async Task Restore_FromTrash_PrevColInbox_ReturnsInbox() { (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст", prevCol: "inbox")); string? back = await service.RestoreCardAsync("l_1", CancellationToken.None); Assert.Equal("inbox", back); CardDto card = Assert.Single(store.CardDtos); Assert.Equal("inbox", card.Col); Assert.True(card.IsNew); } [Fact] public async Task Restore_CardMissing_ReturnsNull() { (CardsService service, _, _, _) = Create(); string? back = await service.RestoreCardAsync("l_ghost", CancellationToken.None); Assert.Null(back); // эндпоинт отвечает 404 «Карточка не найдена» } [Fact] public async Task DeleteForever_RemovesCardAndComments_KeepsJournal() { (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Нужен Python-разработчик", comments: [new CardCommentDto("cm_1", "Вы", "Перезвонить", "5 мин")])); bool deleted = await service.DeleteForeverAsync("l_1", CancellationToken.None); Assert.True(deleted); Assert.DoesNotContain(store.CardDtos, card => card.Id == "l_1"); // карточка удалена Assert.Empty(store.CardDtos); } [Fact] public async Task DeleteForever_JournalRowsSurviveCardDeletion() { (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" }))); store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик")); await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None); // журнал: 1 запись move await service.DeleteForeverAsync("l_1", CancellationToken.None); CardMoveDto move = Assert.Single(store.Moves); Assert.Equal("move", move.Action); Assert.Equal("l_1", move.LeadId); } [Fact] public async Task DeleteForever_CardMissing_ReturnsFalse() { (CardsService service, _, _, _) = Create(); bool deleted = await service.DeleteForeverAsync("l_ghost", CancellationToken.None); Assert.False(deleted); // эндпоинт отвечает 404 «Карточка не найдена» } [Fact] public async Task ClearCol_Trash_ReturnsClearedCount() { (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("l_1", KanbanColumns.Trash)); store.SeedCard(Card("l_2", KanbanColumns.Trash)); store.SeedCard(Card("l_3", KanbanColumns.Archive)); ClearColResultDto result = await service.ClearColAsync(KanbanColumns.Trash, CancellationToken.None); Assert.Null(result.Error); Assert.Equal(2, result.Cleared); Assert.DoesNotContain(store.CardDtos, card => card.Col == KanbanColumns.Trash); Assert.Single(store.CardDtos); // архив не тронут } [Fact] public async Task ClearCol_EmptyTrash_ReturnsZero() { (CardsService service, _, _, _) = Create(); ClearColResultDto result = await service.ClearColAsync(KanbanColumns.Trash, CancellationToken.None); Assert.Null(result.Error); Assert.Equal(0, result.Cleared); } [Fact] public async Task ClearCol_Board_Returns400Text() { (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedBoard(Board("b_py")); store.SeedCard(Card("l_1", "b_py")); ClearColResultDto result = await service.ClearColAsync("b_py", CancellationToken.None); Assert.Equal(CardsService.ClearColInvalidDetail, result.Error); // «Очищать можно только корзину или архив» Assert.Single(store.CardDtos); // ничего не удалено } [Fact] public async Task ClearCol_Inbox_Returns400Text() { (CardsService service, _, _, _) = Create(); ClearColResultDto result = await service.ClearColAsync(KanbanColumns.Inbox, CancellationToken.None); Assert.Equal(CardsService.ClearColInvalidDetail, result.Error); } [Fact] public async Task MarkSeen_ById_OnlyThatCard() { (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("l_1", "inbox", isNew: true)); store.SeedCard(Card("l_2", "inbox", isNew: true)); await service.MarkSeenAsync("l_1", null, CancellationToken.None); Assert.False(Assert.Single(store.CardDtos, card => card.Id == "l_1").IsNew); Assert.True(Assert.Single(store.CardDtos, card => card.Id == "l_2").IsNew); } [Fact] public async Task MarkSeen_ByCol_OnlyColumnCards() { (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("l_1", "inbox", isNew: true)); store.SeedCard(Card("l_2", "inbox", isNew: true)); store.SeedCard(Card("l_3", "b_py", isNew: true)); await service.MarkSeenAsync(null, "inbox", CancellationToken.None); Assert.DoesNotContain(store.CardDtos, card => card.Col == "inbox" && card.IsNew); Assert.True(Assert.Single(store.CardDtos, card => card.Id == "l_3").IsNew); } [Fact] public async Task MarkSeen_All_WithoutParameters() { (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("l_1", "inbox", isNew: true)); store.SeedCard(Card("l_2", KanbanColumns.Trash, isNew: true)); await service.MarkSeenAsync(null, null, CancellationToken.None); Assert.All(store.CardDtos, card => Assert.False(card.IsNew)); } [Fact] public async Task AddComment_EmptyOrWhitespaceText_Returns400Text() { (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("l_1", "inbox")); AddCommentResultDto empty = await service.AddCommentAsync("l_1", " ", CancellationToken.None); AddCommentResultDto nullText = await service.AddCommentAsync("l_1", string.Empty, CancellationToken.None); Assert.Equal(CardsService.EmptyCommentDetail, empty.Error); // «Пустой комментарий» Assert.Equal(CardsService.EmptyCommentDetail, nullText.Error); Assert.Empty(store.Moves); } [Fact] public async Task AddComment_Valid_AppendsTrimmedCommentWithJournal() { (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("l_1", "inbox")); AddCommentResultDto result = await service.AddCommentAsync("l_1", " Перезвонить завтра ", CancellationToken.None); Assert.Null(result.Error); CardCommentDto comment = Assert.Single(result.Comments!); Assert.StartsWith("cm_", comment.Id); // id комментария (Ruling 12) Assert.Equal("Вы", comment.By); Assert.Equal("Перезвонить завтра", comment.Text); // Trim (L262) Assert.Equal("только что", comment.Time); CardMoveDto move = Assert.Single(store.Moves); // комментарий — тоже действие журнала (L264) Assert.Equal("comment", move.Action); Assert.Null(move.FromCol); Assert.Null(move.ToCol); Assert.Single(Assert.Single(store.CardDtos).Comments); // комментарий сохранён на карточке } [Fact] public async Task AddComment_CardMissing_ReturnsNullComments() { (CardsService service, _, _, _) = Create(); AddCommentResultDto result = await service.AddCommentAsync("l_ghost", "Текст", CancellationToken.None); Assert.Null(result.Error); Assert.Null(result.Comments); // эндпоинт отвечает 404 «Карточка не найдена» } [Fact] public async Task Counts_ColumnsNewAndMlStats() { (CardsService service, TestKanjStore 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")); store.SeedCard(Card("l_4", KanbanColumns.Trash, isNew: true)); ml.Status = MlStatus(learning: 7, ml: 2, ai: 3); CardCountsDto counts = await service.CountsAsync(CancellationToken.None); Assert.Equal(2, counts.New); // top-level new = сумма по колонкам (L274) Assert.Equal(new CardColumnCountDto(2, 1), counts.Columns["inbox"]); Assert.Equal(new CardColumnCountDto(1, 0), counts.Columns["b_py"]); Assert.Equal(new CardColumnCountDto(1, 1), counts.Columns[KanbanColumns.Trash]); Assert.Equal(7, counts.Learning); // из IMlClient.StatusAsync (план L321) Assert.Equal(2, counts.Ml); Assert.Equal(3, counts.Ai); } [Fact] public async Task Counts_EmptyStore_ZeroNewAndStats() { (CardsService service, _, _, _) = Create(); CardCountsDto counts = await service.CountsAsync(CancellationToken.None); Assert.Equal(0, counts.New); Assert.Empty(counts.Columns); Assert.Equal(0, counts.Learning); } [Fact] public async Task Search_QueryShorterThanTwoChars_ReturnsEmptyAndDoesNotCallStore() { (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("l_1", "inbox", title: "Python-разработчик")); IReadOnlyList one = await service.SearchCardsAsync("p", CancellationToken.None); IReadOnlyList blank = await service.SearchCardsAsync(" ", CancellationToken.None); Assert.Empty(one); // min 2 символа (L511–512) Assert.Empty(blank); Assert.Empty(store.SearchCalls); // порт не вызывается — поиск не имеет смысла } [Fact] public async Task Search_DelegatesToStoreWithQueryAndLimit() { (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("l_1", "inbox", title: "Middle Python-разработчик", receivedAtMs: 300)); IReadOnlyList result = await service.SearchCardsAsync(" pYtHoN ", CancellationToken.None); CardDto card = Assert.Single(result); Assert.Equal("l_1", card.Id); (string query, int limit) = Assert.Single(store.SearchCalls); Assert.Equal("python", query); Assert.Equal(12, limit); } [Fact] public async Task Search_FindsByTitleSummaryContactSourceText_OrderedByReceivedAtDesc() { (CardsService service, TestKanjStore store, _, _) = Create(); store.SeedCard(Card("l_title", "inbox", title: "Middle Python-разработчик", receivedAtMs: 300)); store.SeedCard(Card("l_summary", "b_py", summary: "проект на python", receivedAtMs: 200)); store.SeedCard(Card("l_contact", "inbox", contact: "@python_dev", receivedAtMs: 100)); store.SeedCard(Card("l_msg", "inbox", sourceMsg: "Нужен GO-разработчик", receivedAtMs: 400)); IReadOnlyList result = await service.SearchCardsAsync("pYtHoN", CancellationToken.None); // Регистр не важен (lower LIKE %q%), порядок — received_at DESC. Assert.Equal(new[] { "l_title", "l_summary", "l_contact" }, result.Select(card => card.Id)); } [Fact] public async Task Search_LimitIsTwelve() { (CardsService service, TestKanjStore store, _, _) = Create(); for (int i = 0; i < 15; i++) { store.SeedCard(Card($"l_{i:00}", "inbox", title: $"python {i}", receivedAtMs: i)); } IReadOnlyList result = await service.SearchCardsAsync("python", CancellationToken.None); Assert.Equal(12, result.Count); // limit=12 (L509) Assert.Equal("l_14", result[0].Id); // свежие сверху Assert.Equal("l_03", result[^1].Id); // 12 из 15 самых свежих } // ─── Хелперы ────────────────────────────────────────────────────────── private static (CardsService Service, TestKanjStore Store, TestSettingsStore Settings, TestMlClient Ml) Create() { var store = new TestKanjStore(); var settings = new TestSettingsStore(); var ml = new TestMlClient(); return (new CardsService(store.Store, settings.Store, ml.Client, new TestFileStorage().Storage), store, settings, ml); } // Доска с правилами (ContainerRulesDto) либо без них. private static ContainerDto Board(string id, ContainerRulesDto? rules = null) => new() { Id = id, Name = id, Rules = rules }; // Правила колонки с непустыми группами из параметров (как в ColumnRulesTests). private static ContainerRulesDto Rules( string mode = "all", string[]? stack = null, string[]? grade = null, BudgetRangeDto? budget = null) { return new ContainerRulesDto( mode, Array.Empty(), Array.Empty(), stack ?? Array.Empty(), grade ?? Array.Empty(), Array.Empty(), budget); } // Карточка с полями по умолчанию (col=inbox, isNew=false, prevCol=inbox). private static CardDto Card( string id, string col, bool isNew = false, string title = "", string summary = "", string contact = "", string sourceMsg = "", string prevCol = KanbanColumns.Inbox, long receivedAtMs = 0, IReadOnlyList? comments = null) { return new CardDto { Id = id, Col = col, IsNew = isNew, Title = title, Summary = summary, Contact = contact, Content = new SourceContent { Text = sourceMsg }, PrevCol = prevCol, ReceivedAtMs = receivedAtMs, Comments = comments ?? Array.Empty(), }; } // Ответ IMlClient.StatusAsync с заданными счётчиками (learning/ml/ai) для counts. private static MlStatusResponseDto MlStatus( int learning, int ml, int ai) { var service = new MlServiceStatusDto( Ready: false, Classes: new Dictionary(), Learned: 0, Eval: new MlEvalDto(Count: 0, Correct: 0, Accuracy: 0.0)); var stats = new MlStatsDto( Ml: ml, Ai: ai, Learning: learning, Ready: false, Classes: new Dictionary(), Learned: 0, Reachable: true, Outbox: 0); return new MlStatusResponseDto(Enabled: true, Service: service, Reachable: true, Stats: stats); } }