Files
Deal/src/core/tests/Deal.Tests.Unit/Contracts/CardsServiceTests.cs
T
Rustam Khalimov 5bfa92a4ac Сгруппировать тестовые проекты по областям
Deal.Tests.Unit, Deal.Telegram.Tests, Deal.Ai.Tests, Deal.Ml.Tests
разложены по областям (Modules/<X>, Api, Infrastructure, Contracts,
Grpc, ...), общие хелперы -> Support; namespace = папка, using
между областями добавлены итеративно по ошибкам сборки.
2026-09-11 13:28:26 +03:00

825 lines
37 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.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Kanban.Application.ColumnRules;
using Deal.Tests.Unit.Support;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Contracts;
/// <summary>
/// Тесты CardsService — карточки: чтение, переносы, архив/корзина, комментарии, counts, поиск
/// (план Task 7 L305334; leads.py L151279, L509551; Rulings 2/4/10; Ruling 6 — поиск).
/// </summary>
/// <remarks>
/// Служебные фейки: <see cref="FakeKanjStore"/> (карточки/журнал/комментарии), <see cref="FakeSettingsStore"/>
/// (ratesCache для бюджетных правил; пустое хранилище = мок-курсы, как RatesService) и
/// <see cref="FakeMlClient"/> (PushAsync/StatusAsync). Тексты ошибок сверяются с константами
/// CardsService (строки прототипа leads.py L183184, L239240; dashboard_routes L240241). Журнал
/// проверяется по записям FakeKanjStore.Moves (id lm_+hex, action, from/to), обучение — по FakeMlClient.Pushed.
/// </remarks>
public sealed class CardsServiceTests
{
// ─── Чтение (list_leads/get_lead L151160) ─────────────────────────────
[Fact]
public async Task ListCards_NoCol_ReturnsAllOrderedByReceivedAtDesc()
{
(CardsService service, FakeKanjStore 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, FakeKanjStore 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, FakeKanjStore 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);
}
// ─── Перенос (move_lead L177191, _move L163174) ──────────────────────
[Fact]
public async Task Move_ToBoardWithRules_UpdatesColumnAndComputesMatchHits()
{
(CardsService service, FakeKanjStore 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, FakeKanjStore store, _, FakeMlClient 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, FakeKanjStore store, _, FakeMlClient 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, FakeKanjStore store, _, FakeMlClient 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();
// Валидация цели — до чтения карточки (leads.py L183184): 400, а не 404.
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, FakeKanjStore store, _, FakeMlClient 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, FakeKanjStore store, _, FakeMlClient 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);
// Guard «в ту же колонку» (leads.py _move L165166): карточка не меняется, журнал/обучение не пишутся.
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, FakeKanjStore store, _, FakeMlClient 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, FakeKanjStore store, _, FakeMlClient 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_EmptySourceMsg_UsesTitleForMatchHitsAndPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient 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);
// Текст для правил и обучения = source_msg.strip() or title (L167, L189191).
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, FakeKanjStore store, _, FakeMlClient 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, FakeKanjStore 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, FakeKanjStore store, _, FakeMlClient 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);
// Прямой move из архива миновал бы снятие метки «спам» возврата (Ruling 4, Push 1.0 только у
// restore_lead) — из archive/trash карточку выводит только «Вернуть» (кнопка restore).
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, FakeKanjStore store, _, FakeMlClient 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);
// Из корзины карточка возвращается только кнопкой «Вернуть» (restore снимает спам-обучение):
// прямой move в доску «воскресил» бы карточку без unlearn (Ruling 4).
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);
}
// ─── Корзина (trash_lead L194201) ─────────────────────────────────────
[Fact]
public async Task Trash_FromInbox_MovesToTrashWithJournalAndSpamPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient 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, FakeKanjStore store, _, FakeMlClient 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, FakeKanjStore store, _, FakeMlClient 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, _, _, FakeMlClient ml) = Create();
CardDto? result = await service.TrashCardAsync("l_ghost", CancellationToken.None);
Assert.Null(result); // эндпоинт отвечает 404 «Карточка не найдена»
Assert.Empty(ml.Pushed);
}
// ─── Возврат (restore_lead L204222) ───────────────────────────────────
[Fact]
public async Task Restore_FromTrash_ToPrevColBoard_UnlearnsSpam()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient 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, FakeKanjStore store, _, FakeMlClient 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, FakeKanjStore store, _, FakeMlClient ml) = Create();
// Доска b_gone удалена — prev_col больше не валидна → возврат в inbox (L209).
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, FakeKanjStore 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 «Карточка не найдена»
}
// ─── Удаление навсегда (delete_forever L225234) ───────────────────────
[Fact]
public async Task DeleteForever_RemovesCardAndComments_KeepsJournal()
{
(CardsService service, FakeKanjStore 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, FakeKanjStore 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);
// Журнал живёт дольше карточки (Ruling 1/10: CardMoves без FK, _hard_delete их не чистит).
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 «Карточка не найдена»
}
// ─── Очистка колонки (clear_col L237–247) ──────────────────────────────
[Fact]
public async Task ClearCol_Trash_ReturnsClearedCount()
{
(CardsService service, FakeKanjStore 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, FakeKanjStore 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);
}
// ─── Пометить прочитанным (mark_seen L250–256) ─────────────────────────
[Fact]
public async Task MarkSeen_ById_OnlyThatCard()
{
(CardsService service, FakeKanjStore 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, FakeKanjStore 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, FakeKanjStore 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));
}
// ─── Комментарии (add_comment L259265) ────────────────────────────────
[Fact]
public async Task AddComment_EmptyOrWhitespaceText_Returns400Text()
{
(CardsService service, FakeKanjStore 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, FakeKanjStore 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 «Карточка не найдена»
}
// ─── Счётчики (counts L268–279) ────────────────────────────────────────
[Fact]
public async Task Counts_ColumnsNewAndMlStats()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient 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);
}
// ─── Поиск (search L509551; FTS + LIKE — адаптер, Ruling 6/Task 12) ────────────
[Fact]
public async Task Search_QueryShorterThanTwoChars_ReturnsEmptyAndDoesNotCallStore()
{
(CardsService service, FakeKanjStore 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, FakeKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox", title: "Middle Python-разработчик", receivedAtMs: 300));
IReadOnlyList<CardDto> result = await service.SearchCardsAsync(" pYtHoN ", CancellationToken.None);
// Порт вызывается с trim+lowercase запросом и лимитом 12 (Ruling 6); результат — как вернул порт.
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_FindsByTitleSummaryContactSourceMsg_OrderedByReceivedAtDesc()
{
(CardsService service, FakeKanjStore 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, FakeKanjStore 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, FakeKanjStore Store, FakeSettingsStore Settings, FakeMlClient Ml) Create()
{
var store = new FakeKanjStore();
var settings = new FakeSettingsStore();
var ml = new FakeMlClient();
return (new CardsService(store, settings, ml, new FakeFileStorage()), 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,
SourceMsg = 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);
}
}