Решение по TD-STYLE-ANALYZERS: LF — инструменты проекта (Python/Node) пишут LF, CRLF-.sh не работают на Linux CI (sh scripts/ci.sh), большинство файлов уже были LF. Добавлен .gitattributes (* text=auto eol=lf, бинарные исключения), .editorconfig переведён на lf, 1029 файлов конвертированы, git add --renormalize. Из индекса убраны закравшиеся archive/**/__pycache__/*.pyc.
577 lines
24 KiB
C#
577 lines
24 KiB
C#
using System.Text.Json.Nodes;
|
||
using Deal.Ai.Llm;
|
||
using Deal.Grpc.Ai;
|
||
using Grpc.Core;
|
||
using Microsoft.Extensions.DependencyInjection;
|
||
|
||
namespace Deal.Ai.Tests.Ai;
|
||
|
||
/// <summary>
|
||
/// In-proc gRPC-тесты AiService поверх фейк-провайдера
|
||
/// </summary>
|
||
public sealed class AiRpcTests
|
||
{
|
||
// Имя провайдера сценариев (для текста ошибки UNAVAILABLE).
|
||
private const string ProviderId = "deepseek";
|
||
|
||
// Usage API-ответа сценариев (проверка проброса в reply).
|
||
private static readonly ProviderUsage SampleUsage = new(11, 5, 16);
|
||
|
||
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);
|
||
|
||
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 — по умолчанию пропуск
|
||
/// </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: провайдер недоступен после ретраев
|
||
/// </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
|
||
/// </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 = извлечённый ответ строкой
|
||
/// </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-ошибка; 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
|
||
/// </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
|
||
/// </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);
|
||
|
||
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 + причина; промпт собран сервисом из описания и ключей, сообщение — как «Сообщение:\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: ключи задачи подставляются в промпт строкой через запятую.
|
||
/// </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 без причины модели — причина по умолчанию «не подходит»; строковое «нет» трактуется как ложь.
|
||
/// </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, причина из модели.
|
||
/// </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 символов.
|
||
/// </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>
|
||
/// Конфиг-валидация
|
||
/// </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>
|
||
/// Конфиг-валидация
|
||
/// </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
|
||
/// </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
|
||
/// </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
|
||
/// </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);
|
||
}
|
||
}
|