Files
Deal/src/core/tests/Deal.Tests.Unit/Contracts/CardsServiceTests.cs
T
stepan 93cadcb689
ci / build-test (pull_request) Successful in 3m13s
Перевести FakeKanjStore на NSubstitute
2026-09-13 03:00:01 +03:00

793 lines
33 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// Тесты CardsService — карточки
/// </summary>
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<CardDto> 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<CardDto> 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); // пустой текст не учим (L190191: 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 6002 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 в диапазоне 10003000 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 L165166: в ту же колонку не переносим
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<CardDto> one = await service.SearchCardsAsync("p", CancellationToken.None);
IReadOnlyList<CardDto> blank = await service.SearchCardsAsync(" ", CancellationToken.None);
Assert.Empty(one); // min 2 символа (L511512)
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<CardDto> 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<CardDto> 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<CardDto> 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<string>(),
Array.Empty<string>(),
stack ?? Array.Empty<string>(),
grade ?? Array.Empty<string>(),
Array.Empty<string>(),
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<CardCommentDto>? 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<CardCommentDto>(),
};
}
// Ответ 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<string, double>(),
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<string, double>(),
Learned: 0,
Reachable: true,
Outbox: 0);
return new MlStatusResponseDto(Enabled: true, Service: service, Reachable: true, Stats: stats);
}
}