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