Сгруппировать тестовые проекты по областям

Deal.Tests.Unit, Deal.Telegram.Tests, Deal.Ai.Tests, Deal.Ml.Tests
разложены по областям (Modules/<X>, Api, Infrastructure, Contracts,
Grpc, ...), общие хелперы -> Support; namespace = папка, using
между областями добавлены итеративно по ошибкам сборки.
This commit is contained in:
Rustam Khalimov
2026-09-11 13:28:26 +03:00
parent e3a2692507
commit 5bfa92a4ac
212 changed files with 1982 additions and 1686 deletions
@@ -0,0 +1,590 @@
using Deal.Ai.Llm;
using Deal.Grpc.Ai;
using Grpc.Core;
using Grpc.Net.Client;
using Microsoft.Extensions.DependencyInjection;
using System.Text.Json.Nodes;
using Deal.Ai.Tests.Support;
namespace Deal.Ai.Tests.Ai;
/// <summary>
/// In-proc gRPC-тесты AiService поверх фейк-провайдера (план Task 8, Acceptance): все 4 RPC
/// (Filter/Classify/GenerateKeywords/EvaluateFit) с подменой LLM-фасада (без сети) через
/// реальный хост (Kestrel HTTP/2, интерцептор service-token). Проверяются: разбор решений и
/// usage в ответах, собранные сервисом промпты, недоступность провайдера → UNAVAILABLE с текстом
/// 1:1 Ruling 5, ответ без JSON в Classify → ok=false (не ошибка), INVALID_ARGUMENT конфига.
/// </summary>
public sealed class AiRpcTests
{
// Имя провайдера сценариев (для текста ошибки UNAVAILABLE).
private const string ProviderId = "deepseek";
// Usage API-ответа сценариев (проверка проброса в reply).
private static readonly ProviderUsage SampleUsage = new(11, 5, 16);
// Текст ошибки UNAVAILABLE 1:1 Ruling 5 / ai.py L115117.
private const string UnavailableDetail =
"ИИ (DeepSeek) не ответил корректно — повторите попытку через несколько секунд";
/// <summary>
/// Filter: модель пропускает сообщение — pass=true с причиной и usage API-ответа.
/// </summary>
[Fact]
public async Task Filter_ModelPasses_ReturnsPassReasonAndUsage()
{
FakeProviderClient fake = Success("""{"pass": true, "reason": "похоже на заявку"}""");
await AiTestHost.RunAsync(Host(fake), async channel =>
{
var client = new AiService.AiServiceClient(channel);
FilterReply reply = await client.FilterAsync(
new FilterRequest
{
Prompt = "Фильтр: {domain}",
Text = "Ищем разработчика на проект",
ProviderConfig = RequestConfig(),
},
Options());
Assert.True(reply.Pass);
Assert.Equal("похоже на заявку", reply.Reason);
AssertUsage(reply.Usage, SampleUsage);
// Сервис передаёт промпт system-сообщением и оборачивает текст как ai.py L193.
FakeProviderCall call = Assert.Single(fake.Calls);
Assert.Equal("Фильтр: {domain}", call.SystemPrompt);
Assert.Equal("Сообщение:\nИщем разработчика на проект", call.UserText);
});
}
/// <summary>
/// Filter: модель отклоняет сообщение — pass=false с причиной отказа.
/// </summary>
[Fact]
public async Task Filter_ModelRejects_ReturnsPassFalse()
{
FakeProviderClient fake = Success("""{"pass": false, "reason": "реклама канала"}""");
await AiTestHost.RunAsync(Host(fake), async channel =>
{
var client = new AiService.AiServiceClient(channel);
FilterReply reply = await client.FilterAsync(
new FilterRequest { Prompt = "Фильтр", Text = "Подпишись на канал", ProviderConfig = RequestConfig() },
Options());
Assert.False(reply.Pass);
Assert.Equal("реклама канала", reply.Reason);
});
}
/// <summary>
/// Filter: модель не вернула pass — по умолчанию пропуск (1:1 ai.py L195: bool(get(pass, true))).
/// </summary>
[Fact]
public async Task Filter_MissingPassField_DefaultsToPass()
{
FakeProviderClient fake = Success("""{"note": "ничего не понял"}""");
await AiTestHost.RunAsync(Host(fake), async channel =>
{
var client = new AiService.AiServiceClient(channel);
FilterReply reply = await client.FilterAsync(
new FilterRequest { Prompt = "Фильтр", Text = "Текст", ProviderConfig = RequestConfig() },
Options());
Assert.True(reply.Pass);
Assert.False(reply.HasReason);
});
}
/// <summary>
/// Filter: провайдер недоступен после ретраев (3 попытки) → UNAVAILABLE с текстом 1:1 Ruling 5
/// (ядро трактует как «ИИ недоступен» и пропускает сообщение локальным путём).
/// </summary>
[Fact]
public async Task Filter_ProviderUnavailable_ThrowsUnavailableWithDetail()
{
FakeProviderClient fake = Failing();
await AiTestHost.RunAsync(Host(fake), async channel =>
{
var client = new AiService.AiServiceClient(channel);
RpcException exception = await Assert.ThrowsAsync<RpcException>(() =>
client.FilterAsync(
new FilterRequest { Prompt = "Фильтр", Text = "Текст", ProviderConfig = RequestConfig() },
Options()).ResponseAsync);
Assert.Equal(StatusCode.Unavailable, exception.StatusCode);
Assert.Equal(UnavailableDetail, exception.Status.Detail);
Assert.Equal(LlmRetryPolicy.AttemptCount, fake.Calls.Count);
});
}
/// <summary>
/// Filter: ответ модели без JSON после ретраев — тоже UNAVAILABLE (у метода нет ok-поля).
/// </summary>
[Fact]
public async Task Filter_AnswerWithoutJson_ThrowsUnavailable()
{
FakeProviderClient fake = new((_, _, _) =>
Task.FromResult(new ProviderChatResult("какой-то текст без json", Usage: null)));
await AiTestHost.RunAsync(Host(fake), async channel =>
{
var client = new AiService.AiServiceClient(channel);
RpcException exception = await Assert.ThrowsAsync<RpcException>(() =>
client.FilterAsync(
new FilterRequest { Prompt = "Фильтр", Text = "Текст", ProviderConfig = RequestConfig() },
Options()).ResponseAsync);
Assert.Equal(StatusCode.Unavailable, exception.StatusCode);
});
}
/// <summary>
/// Classify: модель вернула JSON — ok=true, json = извлечённый ответ строкой (маппинг в ядре),
/// usage пробрасывается.
/// </summary>
[Fact]
public async Task Classify_ModelAnsweredJson_ReturnsOkAndJson()
{
const string modelJson =
"""{"board": "dev", "title": "Нужен python-разработчик", "budget": null, "contacts": []}""";
FakeProviderClient fake = Success(modelJson);
await AiTestHost.RunAsync(Host(fake), async channel =>
{
var client = new AiService.AiServiceClient(channel);
ClassifyReply reply = await client.ClassifyAsync(
new ClassifyRequest
{
SystemPrompt = "Система карточки",
UserContext = "Доски:\n- dev: разработка\nНовое сообщение: ищем middle",
ProviderConfig = RequestConfig(),
},
Options());
Assert.True(reply.Ok);
Assert.True(reply.HasJson);
AssertUsage(reply.Usage, SampleUsage);
JsonObject json = JsonNode.Parse(reply.Json)!.AsObject();
Assert.Equal("dev", (string?)json["board"]);
Assert.Equal("Нужен python-разработчик", (string?)json["title"]);
FakeProviderCall call = Assert.Single(fake.Calls);
Assert.Equal("Система карточки", call.SystemPrompt);
Assert.Equal("Доски:\n- dev: разработка\nНовое сообщение: ищем middle", call.UserText);
});
}
/// <summary>
/// Classify: модель отвечала, но без разбираемого JSON после ретраев → ok=false, НЕ RPC-ошибка
/// (README ai.proto L201204); usage последней попытки в ответе (оценка по символам).
/// </summary>
[Fact]
public async Task Classify_AnswerWithoutJson_ReturnsOkFalseWithUsage()
{
const string systemPrompt = "Система карточки";
const string userContext = "Доски и сообщение для разбора";
const string garbage = "простите, я не умею в json";
FakeProviderClient fake = new((_, _, _) =>
Task.FromResult(new ProviderChatResult(garbage, Usage: null)));
await AiTestHost.RunAsync(Host(fake), async channel =>
{
var client = new AiService.AiServiceClient(channel);
ClassifyReply reply = await client.ClassifyAsync(
new ClassifyRequest
{
SystemPrompt = systemPrompt,
UserContext = userContext,
ProviderConfig = RequestConfig(),
},
Options());
Assert.False(reply.Ok);
Assert.False(reply.HasJson);
Assert.Equal(EstimateTokens(systemPrompt.Length + userContext.Length), reply.Usage.Prompt);
Assert.Equal(EstimateTokens(garbage.Length), reply.Usage.Completion);
Assert.Equal(LlmRetryPolicy.AttemptCount, fake.Calls.Count);
});
}
/// <summary>
/// Classify: провайдер недоступен — UNAVAILABLE (ядро падает в локальный разбор, aiFail).
/// </summary>
[Fact]
public async Task Classify_ProviderUnavailable_ThrowsUnavailable()
{
FakeProviderClient fake = Failing();
await AiTestHost.RunAsync(Host(fake), async channel =>
{
var client = new AiService.AiServiceClient(channel);
RpcException exception = await Assert.ThrowsAsync<RpcException>(() =>
client.ClassifyAsync(
new ClassifyRequest { SystemPrompt = "S", UserContext = "U", ProviderConfig = RequestConfig() },
Options()).ResponseAsync);
Assert.Equal(StatusCode.Unavailable, exception.StatusCode);
Assert.Equal(UnavailableDetail, exception.Status.Detail);
});
}
/// <summary>
/// GenerateKeywords: ключи из JSON-ответа + фиксированный промпт с описанием задачи.
/// </summary>
[Fact]
public async Task GenerateKeywords_ModelReturnedKeywords_ReturnsList()
{
FakeProviderClient fake = Success("""{"keywords": ["стройка", "ремонт квартир", "подряды"]}""");
await AiTestHost.RunAsync(Host(fake), async channel =>
{
var client = new AiService.AiServiceClient(channel);
GenerateKeywordsReply reply = await client.GenerateKeywordsAsync(
new GenerateKeywordsRequest
{
Description = "Поиск каналов со стройкой и ремонтом",
ProviderConfig = RequestConfig(),
},
Options());
Assert.Equal(["стройка", "ремонт квартир", "подряды"], reply.Keywords);
AssertUsage(reply.Usage, SampleUsage);
// Фиксированный промпт (routes L36–47) и пользовательское сообщение с описанием.
FakeProviderCall call = Assert.Single(fake.Calls);
Assert.Contains("эксперт по поиску Telegram-каналов", call.SystemPrompt, StringComparison.Ordinal);
Assert.Contains("Верни строго JSON", call.SystemPrompt, StringComparison.Ordinal);
Assert.Equal("Описание ниши/задачи:\nПоиск каналов со стройкой и ремонтом", call.UserText);
});
}
/// <summary>
/// GenerateKeywords: не-строковые элементы списка пропускаются (чистку делает ядро).
/// </summary>
[Fact]
public async Task GenerateKeywords_NonStringItems_Skipped()
{
FakeProviderClient fake = Success("""{"keywords": ["php", 42, "c#"]}""");
await AiTestHost.RunAsync(Host(fake), async channel =>
{
var client = new AiService.AiServiceClient(channel);
GenerateKeywordsReply reply = await client.GenerateKeywordsAsync(
new GenerateKeywordsRequest { Description = "Разработка", ProviderConfig = RequestConfig() },
Options());
Assert.Equal(["php", "c#"], reply.Keywords);
});
}
/// <summary>
/// GenerateKeywords: модель не вернула ключи — пустой список (не ошибка).
/// </summary>
[Fact]
public async Task GenerateKeywords_NoKeywordsField_ReturnsEmpty()
{
FakeProviderClient fake = Success("""{"note": "не нашёл ключей"}""");
await AiTestHost.RunAsync(Host(fake), async channel =>
{
var client = new AiService.AiServiceClient(channel);
GenerateKeywordsReply reply = await client.GenerateKeywordsAsync(
new GenerateKeywordsRequest { Description = "Разработка", ProviderConfig = RequestConfig() },
Options());
Assert.Empty(reply.Keywords);
});
}
/// <summary>
/// EvaluateFit: fit=1 + причина; промпт собран сервисом из описания и ключей (discovery_eval
/// L50–54), сообщение — как «Сообщение:\n…».
/// </summary>
[Fact]
public async Task EvaluateFit_ModelFits_ReturnsFitAndReason()
{
FakeProviderClient fake = Success("""{"fit": 1, "reason": "строительная тематика"}""");
await AiTestHost.RunAsync(Host(fake), async channel =>
{
var client = new AiService.AiServiceClient(channel);
EvaluateFitReply reply = await client.EvaluateFitAsync(
new EvaluateFitRequest
{
Text = "Нужна бригада отделочников на объект",
Description = "Поиск подрядчиков на отделку",
ProviderConfig = RequestConfig(),
},
Options());
Assert.True(reply.Fit);
Assert.Equal("строительная тематика", reply.Reason);
AssertUsage(reply.Usage, SampleUsage);
FakeProviderCall call = Assert.Single(fake.Calls);
Assert.Equal("Сообщение:\nНужна бригада отделочников на объект", call.UserText);
Assert.Contains("Оцени, относится ли сообщение к сфере/задаче", call.SystemPrompt, StringComparison.Ordinal);
Assert.Contains("Описание: Поиск подрядчиков на отделку.", call.SystemPrompt, StringComparison.Ordinal);
});
}
/// <summary>
/// EvaluateFit: ключи задачи подставляются в промпт строкой через запятую (1:1 _ai_prompt).
/// </summary>
[Fact]
public async Task EvaluateFit_KeywordsJoinedIntoPrompt()
{
FakeProviderClient fake = Success("""{"fit": 0, "reason": "не совпадает"}""");
await AiTestHost.RunAsync(Host(fake), async channel =>
{
var client = new AiService.AiServiceClient(channel);
await client.EvaluateFitAsync(
new EvaluateFitRequest
{
Text = "Текст",
Description = "Описание",
Keywords = { "стройка", " ремонт ", " ", "подряд" },
ProviderConfig = RequestConfig(),
},
Options());
string systemPrompt = Assert.Single(fake.Calls).SystemPrompt;
Assert.Contains("Ключи: стройка, ремонт, подряд.", systemPrompt, StringComparison.Ordinal);
});
}
/// <summary>
/// EvaluateFit: fit=0 без причины модели — причина по умолчанию «не подходит» (1:1 _ai_reason
/// discovery_eval L167171); строковое «нет» трактуется как ложь (1:1 _ai_fit L158164).
/// </summary>
[Fact]
public async Task EvaluateFit_ModelNoFit_ReturnsDefaultReason()
{
FakeProviderClient fake = Success("""{"fit": 0}""");
await AiTestHost.RunAsync(Host(fake), async channel =>
{
var client = new AiService.AiServiceClient(channel);
EvaluateFitReply reply = await client.EvaluateFitAsync(
new EvaluateFitRequest { Text = "Текст", Description = "Описание", ProviderConfig = RequestConfig() },
Options());
Assert.False(reply.Fit);
Assert.Equal("не подходит", reply.Reason);
});
}
/// <summary>
/// EvaluateFit: fit строкой «нет» — false (паритет _ai_fit), причина из модели.
/// </summary>
[Fact]
public async Task EvaluateFit_StringFalsyFit_ReturnsFalse()
{
FakeProviderClient fake = Success("""{"fit": "нет", "reason": "другая сфера"}""");
await AiTestHost.RunAsync(Host(fake), async channel =>
{
var client = new AiService.AiServiceClient(channel);
EvaluateFitReply reply = await client.EvaluateFitAsync(
new EvaluateFitRequest { Text = "Текст", Description = "Описание", ProviderConfig = RequestConfig() },
Options());
Assert.False(reply.Fit);
Assert.Equal("другая сфера", reply.Reason);
});
}
/// <summary>
/// EvaluateFit: длинная причина модели усекается до 200 символов (1:1 _AI_REASON_LIMIT).
/// </summary>
[Fact]
public async Task EvaluateFit_LongReason_IsTruncatedTo200()
{
string longReason = new('а', 250);
FakeProviderClient fake = Success($$"""{"fit": 1, "reason": "{{longReason}}"}""");
await AiTestHost.RunAsync(Host(fake), async channel =>
{
var client = new AiService.AiServiceClient(channel);
EvaluateFitReply reply = await client.EvaluateFitAsync(
new EvaluateFitRequest { Text = "Текст", Description = "Описание", ProviderConfig = RequestConfig() },
Options());
Assert.Equal(200, reply.Reason.Length);
Assert.StartsWith("ааааа", reply.Reason, StringComparison.Ordinal);
});
}
/// <summary>
/// Конфиг-валидация: запрос без конфига провайдера (пустой base_url) → INVALID_ARGUMENT.
/// </summary>
[Fact]
public async Task Classify_WithoutProviderConfig_IsInvalidArgument()
{
FakeProviderClient fake = Success("{}");
await AiTestHost.RunAsync(Host(fake), async channel =>
{
var client = new AiService.AiServiceClient(channel);
RpcException exception = await Assert.ThrowsAsync<RpcException>(() =>
client.ClassifyAsync(
new ClassifyRequest { SystemPrompt = "S", UserContext = "U" },
Options()).ResponseAsync);
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
Assert.Empty(fake.Calls);
});
}
/// <summary>
/// Конфиг-валидация: пустая model конфига → INVALID_ARGUMENT (вызов модели невозможен).
/// </summary>
[Fact]
public async Task GenerateKeywords_EmptyModel_IsInvalidArgument()
{
FakeProviderClient fake = Success("{}");
await AiTestHost.RunAsync(Host(fake), async channel =>
{
var client = new AiService.AiServiceClient(channel);
RpcException exception = await Assert.ThrowsAsync<RpcException>(() =>
client.GenerateKeywordsAsync(
new GenerateKeywordsRequest
{
Description = "Описание",
ProviderConfig = new ProviderConfig { BaseUrl = "https://api.example.com/v1" },
},
Options()).ResponseAsync);
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
Assert.Empty(fake.Calls);
});
}
/// <summary>
/// Серверный лимит text (ai.proto Filter.text: core обрезает до 4000): превышение → INVALID_ARGUMENT.
/// </summary>
[Fact]
public async Task Filter_TooLongText_IsInvalidArgument()
{
FakeProviderClient fake = Success("""{"pass": true}""");
await AiTestHost.RunAsync(Host(fake), async channel =>
{
var client = new AiService.AiServiceClient(channel);
RpcException exception = await Assert.ThrowsAsync<RpcException>(() =>
client.FilterAsync(
new FilterRequest
{
Prompt = "Фильтр",
Text = new string('а', 4001),
ProviderConfig = RequestConfig(),
},
Options()).ResponseAsync);
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
Assert.Empty(fake.Calls);
});
}
/// <summary>
/// Серверный лимит description (ai.proto GenerateKeywords.description: core обрезает до 4000).
/// </summary>
[Fact]
public async Task GenerateKeywords_TooLongDescription_IsInvalidArgument()
{
FakeProviderClient fake = Success("{}");
await AiTestHost.RunAsync(Host(fake), async channel =>
{
var client = new AiService.AiServiceClient(channel);
RpcException exception = await Assert.ThrowsAsync<RpcException>(() =>
client.GenerateKeywordsAsync(
new GenerateKeywordsRequest
{
Description = new string('а', 4001),
ProviderConfig = RequestConfig(),
},
Options()).ResponseAsync);
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
Assert.Empty(fake.Calls);
});
}
/// <summary>
/// Обязательный tenant-id в metadata (Ruling 1): отсутствует → UNAUTHENTICATED до вызова.
/// </summary>
[Fact]
public async Task Classify_WithoutTenantId_IsUnauthenticated()
{
FakeProviderClient fake = Success("{}");
await AiTestHost.RunAsync(Host(fake), async channel =>
{
var client = new AiService.AiServiceClient(channel);
Metadata metadata = AiTestHost.CallMetadata(AiTestHost.DefaultToken, tenantId: null);
RpcException exception = await Assert.ThrowsAsync<RpcException>(() =>
client.ClassifyAsync(
new ClassifyRequest { SystemPrompt = "S", UserContext = "U", ProviderConfig = RequestConfig() },
AiTestHost.CallOptions(metadata)).ResponseAsync);
Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode);
Assert.Empty(fake.Calls);
});
}
// Конфиг DI сценария: фейк-провайдер + мгновенные паузы ретраев.
// fake: Фейк-провайдер вызовов.
private static Action<IServiceCollection> Host(FakeProviderClient fake)
=> services =>
{
AiTestHost.UseFakeProvider(services, fake);
AiTestHost.DisableRetryDelays(services);
};
// CallOptions с service-token и tenant-id сценария.
private static CallOptions Options()
=> AiTestHost.CallOptions(
AiTestHost.CallMetadata(AiTestHost.DefaultToken, AiTestHost.DefaultTenantId));
// Конфиг провайдера запроса (зеркало того, что ядро кладёт в тело запроса).
private static ProviderConfig RequestConfig()
=> new()
{
ProviderId = ProviderId,
BaseUrl = "https://api.example.com/v1",
Model = "deepseek-v4-flash",
ApiKey = "test-key",
};
// Фейк, всегда отвечающий заданным JSON-текстом с usage API-ответа.
// jsonText: JSON-текст ответа модели.
private static FakeProviderClient Success(string jsonText)
=> new((_, _, _) => Task.FromResult(new ProviderChatResult(jsonText, SampleUsage)));
// Фейк, всегда падающий транспортной ошибкой (повод для ретрая).
private static FakeProviderClient Failing()
=> new((_, _, _) => Task.FromException<ProviderChatResult>(new LlmHttpException("сеть недоступна")));
// Оценка токенов по символам (зеркало TokenEstimator; для проверки usage в ответе).
// charCount: Число символов.
private static uint EstimateTokens(int charCount)
=> (uint)((charCount + 3) / 4);
// Проверяет проброс usage провайдера в gRPC-ответ.
// usage: Usage ответа RPC.
// expected: Ожидаемое значение.
private static void AssertUsage(Deal.Grpc.Ai.Usage usage, ProviderUsage expected)
{
Assert.Equal((uint)expected.PromptTokens, usage.Prompt);
Assert.Equal((uint)expected.CompletionTokens, usage.Completion);
Assert.Equal((uint)expected.TotalTokens, usage.Total);
}
}